%%{init: {'theme': 'base', 'themeVariables': {'fontFamily': 'monospace', 'darkMode': true}}}%%
flowchart TD
Shiny(["Shiny App"])
Request["POST /predict<br/>(penguin data)"]
Router["Plumber Router"]
Match["Route Match"]
Handler["handle_predict()"]
Result["list(.pred = 3897)"]
JSON["JSON Response"]
Return(["Return to Shiny"])
Shiny -->|send| Request
Request --> Router
Router --> Match
Match -->|dispatch| Handler
Handler --> Result
Result -->|serialize| JSON
JSON --> Return
Return --> Shiny
style Shiny fill:#FFF8E7,stroke:#999,color:#000000
style Match color:#000000,fill:#FFF8E7,stroke:#999
style Result color:#000000,fill:#FFF8E7,stroke:#999
style Return color:#000000,fill:#FFF8E7,stroke:#999
style Router fill:#4CBB9D,stroke:#4CBB9D,color:#FFFFFF,rx:5,ry:5
style Handler fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
style Request fill:#FFFFFF,stroke:#333,color:#000000
style JSON fill:#FFFFFF,stroke:#333,color:#000000
I’ve been working through the exercises in Alex K Gold’s DevOps for Data Science and this week I finished the API labs. When the book was written, plumber2 hadn’t been released yet. I went ahead and completed the exercises using both plumber and plumber2.1
In this post, we’ll explore:
- What
plumberandplumber2do - The syntax changes between versions (with side-by-side comparisons)
- A working example using a
penguinprediction model - Logging with both versions
- Why the migration might be worth it
What is plumber?
Both plumber and plumber2 turn R functions into REST APIs. We can also use them to write handlers that respond to HTTP requests, wire them into a router, and launch a server. Clients (Shiny apps, JavaScript frontends, Python scripts) make HTTP requests and get JSON responses back.
To get started:
install.packages("plumber2")Key changes
The major differences between plumber and plumber2 are syntactic. The architecture and concepts are the same; the code looks different.
Comparison table
| Task | plumber |
plumber2 |
|---|---|---|
| Create router | pr() |
api() |
| Add GET route | pr_get() |
api_get() |
| Add POST route | pr_post() |
api_post() |
| Start server | pr_run() |
api_run() |
| JSON serializer | serializer_json() |
format_json() |
| Request body | jsonlite::fromJSON(req$postBody) |
body argument (auto-parsed) |
| Response status | res$status <- 500 |
response$status <- 500L |
| Annotation comment | #' |
#* |
Parameter handling
This is the biggest practical change. In plumber, all request data (query, body, path) mixes into function arguments. We parsed the body manually.
%%{init: {'theme': 'base', 'themeVariables': {'fontFamily': 'monospace', 'darkMode': true}}}%%
flowchart LR
subgraph Plumber["<strong><code>plumber</code></strong>"]
direction TB
ReqPlumb("HTTP Request")
ParsePlumb["<code>query</code>, <code>body</code>, <code>path</code> into function<br/>args"]
ManualPlumb(["<code>req$postBody</code><br/>+ jsonlite"])
ReqPlumb --> ParsePlumb
ParsePlumb --> ManualPlumb
end
style Plumber stroke:#999,color:#000000,rx:5,ry:5
style ReqPlumb fill:#FFFFFF,stroke:#333,color:#000000
style ParsePlumb fill:#FFF8E7,stroke:#999,color:#000000
style ManualPlumb fill:#FFE0E0,stroke:#c0392b,color:#000000
In plumber2, they’re separated:
- The path parameters are now named function arguments (e.g.,
/users/<id>→function(id)) - Query parameters are accessed via the
queryargument
- The request body is accessed via the
bodyargument (already parsed JSON)
- Response control uses the
responseargument to set status codes
%%{init: {'theme': 'base', 'themeVariables': {'fontFamily': 'monospace', 'darkMode': true}}}%%
flowchart LR
subgraph Plumber2["<strong><code>plumber2<code></strong>"]
direction TB
ReqP2("HTTP Request")
PathP2(["<code>path</code><br/>(named args)"])
QueryP2(["<code>query</code><br/>(auto)"])
BodyP2(["<code>body</code><br/>(auto-parsed)"])
ReqP2 --> PathP2
ReqP2 --> QueryP2
ReqP2 --> BodyP2
end
style Plumber2 stroke:#999,color:#000000,rx:5,ry:5
style ReqP2 fill:#FFFFFF,stroke:#333,color:#000000
style PathP2 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
style QueryP2 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
style BodyP2 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
Example: Penguin prediction API
Both book chapters use the same example: building an API that predicts penguin body mass from bill length, species, and sex. Let’s see how the syntax differs for the same handler.
Helper function (identical in both)
Both versions use this helper to convert JSON strings to factors (which the model expects):
prep_pred_data <- function(input_data) {
species_levels <- levels(v$prototype$species)
sex_levels <- levels(v$prototype$sex)
data.frame(
bill_length_mm = as.numeric(input_data$bill_length_mm),
species = factor(input_data$species, levels = species_levels),
sex = factor(input_data$sex, levels = sex_levels),
stringsAsFactors = FALSE
)
}- 1
- Extract factor levels from the vetiver model prototype
plumber version
From the plumber.R file:
show/hide plumber handle_predict()
#* Predict penguin body mass
#*
#* Main prediction endpoint that accepts penguin characteristics and returns
#* predicted body mass in grams.
#*
#* @post /predict
#* @serializer json
handle_predict <- function(req, res) {
result <- tryCatch({
body <- jsonlite::fromJSON(rawToChar(req$postBody))
if (is.list(body) && !is.data.frame(body)) {
body <- as.data.frame(body)
}
pred_data <- prep_pred_data(body)
prediction <- predict(v, pred_data)
list(.pred = as.numeric(prediction))
}, error = function(e) {
res$status <- 400
list(error = e$message)
})
result
}- 1
- Handler receives raw request and response objects
- 2
- Manual JSON parsing from request body
- 3
- Prepare data with factor conversion
- 4
- Make prediction using vetiver model
- 5
- Set HTTP status on error
plumber2 version
From the plumber2.R file:
show/hide plumber2 handle_predict()
#* Predict penguin body mass
#*
#* @post /predict
#* @serializer json
handle_predict <- function(body, response) {
result <- tryCatch({
if (is.list(body) && !is.data.frame(body)) {
body <- as.data.frame(body)
}
pred_data <- prep_pred_data(body)
prediction <- predict(v, pred_data)
if (is.data.frame(prediction) && ".pred" %in% names(prediction)) {
list(.pred = prediction$.pred)
} else if (is.numeric(prediction)) {
list(.pred = as.numeric(prediction))
}
}, error = function(e) {
response$status <- 400L
list(error = e$message)
})
result
}- 1
-
Handler receives parsed
bodyandresponseobjects directly - 2
- Body is already parsed; no manual JSON conversion needed
- 3
- Prepare data with factor conversion
- 4
- Make prediction using vetiver model
- 5
- Set HTTP status on error
Testing with curl
Both APIs respond identically:
curl -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{"bill_length_mm": 45, "species": "Adelie", "sex": "male"}'{
".pred": [3897.5]
}The key difference: plumber requires us to parse req$postBody manually; plumber2 provides body already parsed. For everything else, the logic is identical.
Logging
Both versions work with the logger package, and the calls look the same either way. Here’s a health check endpoint that writes a line every time it’s hit:
#* Basic health check
#* @get /ping
#* @serializer json
handle_ping <- function() {
logger::log_info("Health check requested")
list(status = "alive", timestamp = Sys.time())
}Swap library(plumber) for library(plumber2) and this handler doesn’t change. The difference only shows up when we want request-level detail in the message: plumber makes us dig it out of req, while plumber2 hands us body and response as arguments (covered in the parameter handling section above).
The logs I actually reach for come from the client side. In the logging lab I used logger instead of the book’s suggested log4r, mostly because I’m more familiar with its features.2 Configuration is three calls: set the threshold, point the appender at a file, and pick a formatter.
logger::log_threshold(level = "DEBUG")
logger::log_appender(appender = appender_tee(file = "shiny_app.log"))
logger::log_formatter(logger::formatter_glue_or_sprintf)appender_tee() writes to both the console and shiny_app.log, and the glue/sprintf formatter keeps messages readable when we display them back in the UI.
Session startup and user input
Session startup goes in an observer with a high priority so it runs before anything else in the server function:
observe({
logger::log_info("Shiny app started - Session: {session$token} - Host: {session$clientData$url_hostname}")
}, priority = 1000)Input changes are worth capturing at DEBUG, but an unthrottled observer fires on every keystroke. throttle() caps it at one message every two seconds:
observe({
logger::log_debug("User input changed - Session: {session$token} - bill_length: {input$bill_length} - species: {input$species} - sex: {input$sex}")
}) |>
throttle(2000)Logging the API request
This is where the logs earn their keep. The prediction reactive wraps the httr2 call in tryCatch(), measures how long the API took to answer, and writes a success line, a slow-response warning, or an error:
show/hide pred() with response-time logging
pred <- reactive({
request_start <- Sys.time()
request_data <- vals()
logger::log_info(
"Starting prediction request - Session: {session$token} - request_data: {jsonlite::toJSON(request_data, auto_unbox = TRUE)}"
)
tryCatch({
response <- httr2::request(api_url) |>
httr2::req_method("POST") |>
httr2::req_body_json(request_data, auto_unbox = TRUE) |>
httr2::req_timeout(30) |>
httr2::req_perform()
response_time <- as.numeric(difftime(Sys.time(), request_start, units = "secs"))
response_data <- httr2::resp_body_json(response)
prediction_value <- as.numeric(response_data$.pred[[1]])
logger::log_info(
"Prediction successful - Session: {session$token} - response_time_sec: {round(response_time, 3)} - prediction: {prediction_value}"
)
if (response_time > 5) {
logger::log_warn("Slow API response - Session: {session$token} - response_time_sec: {response_time}")
}
prediction_value
}, error = function(e) {
logger::log_error(
"Prediction request failed - Session: {session$token} - error: {conditionMessage(e)}"
)
paste("Error:", conditionMessage(e))
})
}) |>
bindEvent(input$predict, ignoreInit = TRUE)Three levels do the work. INFO records the outgoing request and the successful prediction with its response time, WARN flags anything slower than five seconds (a threshold worth tuning to the API), and ERROR captures the condition message when the request never comes back.
The health check follows the same shape: the app pings /ping and logs at DEBUG when the check starts, INFO on a 200, WARN on any other status, and ERROR when the request throws. And because appender_tee() is writing to a file, we can read that file back into the app with reactiveFileReader(), poll it every second, and show the last five log lines in the UI.
Every request writes a session-scoped trail, with the level chosen by what actually happened:
%%{init: {'theme': 'neutral', 'themeVariables': {'fontFamily': 'monospace', 'darkMode': true}}}%%
sequenceDiagram
participant Shiny as Shiny App
participant API as plumber2 API
participant Log as logger
participant File as shiny_app.log
Shiny->>Log: INFO "Starting prediction request"
Log->>File: write
Shiny->>API: POST /predict
API-->>Shiny: {".pred": [4180.797]}
alt response_time <= 5s
Shiny->>Log: INFO "Prediction successful" + response_time
Log->>File: write
else response_time > 5s
Shiny->>Log: WARN "Slow API response"
Log->>File: write
else request errors
Shiny->>Log: ERROR "Prediction request failed"
Log->>File: write
end
Using from Shiny
From the Shiny side, both APIs look identical. The book uses this pattern to make requests:
library(shiny)
library(httr2)
ui <- fluidPage(
numericInput("bill_length", "Bill length (mm)", 45),
selectInput("species", "Species", c("Adelie", "Chinstrap", "Gentoo")),
actionButton("predict", "Predict"),
verbatimTextOutput("result")
)
server <- function(input, output, session) {
pred <- eventReactive(input$predict, {
req <- request("http://localhost:8000/predict") |>
req_method("POST") |>
req_body_json(list(
bill_length_mm = input$bill_length,
species = input$species,
sex = "male"
))
response <- req_perform(req)
resp_body_json(response)
})
output$result <- renderPrint(pred())
}
shinyApp(ui, server)Whether the API is built with plumber or plumber2, the client code stays the same.
Migration
After moving the existing plumber API to plumber2, here’s my mental model of the two packages:
%%{init: {'theme': 'base', 'themeVariables': {'fontFamily': 'monospace', 'darkMode': true}}}%%
flowchart TD
Start(["Start: <code>plumber</code> API"])
Step1["Replace <code>pr()</code><br/>with <code>api()</code>"]
Step2["Change <code>#'</code> to <code>#*</code><br/>annotations"]
Step3["Update handlers:<br/><code>req</code>,<code>res</code> → <code>body</code>,<code>query</code>"]
Step4["Remove manual<br/>JSON parsing"]
Step5["Update serializers<br/>& status codes"]
Done(["Done: <code>plumber2</code> API"])
Start --> Step1
Step1 --> Step2
Step2 --> Step3
Step3 --> Step4
Step4 --> Step5
Step5 --> Done
style Step1 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
style Step2 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
style Step3 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
style Step4 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
style Step5 fill:#E0F0ED,stroke:#4CBB9D,color:#000000,rx:5,ry:5
style Start fill:#FFF8E7,stroke:#999,color:#000000
style Done fill:#4CBB9D,stroke:#4CBB9D,color:#FFFFFF,rx:5,ry:5
The logic of the handlers stays the same; we’re updating the plumbing.
Why migrate?
plumber2 separates concerns better. Request and response objects are explicit, not hidden in function argument parsing. This makes middleware, error handling, and logging clearer. It also aligns with web framework conventions from other languages (Express.js, Flask), so patterns transfer between ecosystems.
You don’t have to migrate if your plumber API is working fine. But for new APIs, plumber2 is the recommended path.
Recap
Both plumber and plumber2 turn R into an API engine. The syntax and internal structure differ; the core idea remains: handlers respond to routes, wire into a router, serve responses. Logging works in both; plumber2 just makes request/response details more accessible.
The real value comes from integrating APIs with apps. A Shiny frontend, a Python dashboard, or a mobile client can all consume the same API. Build once, use everywhere.
For working examples and detailed guidance, see:
Footnotes
Read a full description of the lab solutions for
plumberandplumber2in my DO4DS: Lab Solutions.↩︎The full application is in the R App Logging lab of my DO4DS: Lab Solutions.↩︎