โ† All guides

Mock a REST API for R โ€” httr2, data.frames both directions, and retry drills

R scripts and Shiny apps lean on APIs constantly โ€” and the usual development answers all hurt a little:

The boring fix is a hosted mock API: a real https URL that answers your console, your CI, your colleague's laptop and your deployed Shiny app alike. Every R snippet below was run verbatim with R 4.3 + httr2 1.0 against the live endpoints on this page before publishing โ€” the printed outputs are from those runs.

1. Try it in 10 seconds (no signup)

A shared, self-resetting demo project is live right now:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"

Or skip the terminal entirely:

jsonlite::fromJSON("https://mockbird.mockbird.workers.dev/m/demo/products?limit=3")

2. Fetch with httr2

The modern R HTTP client is httr2: build a request with pipes, perform it, parse the response. Sorting, filtering and pagination are query params on the mock, so req_url_query() does the heavy lifting:

library(httr2)

resp <- request("https://mockbird.mockbird.workers.dev/m/demo") |>
  req_url_path_append("products") |>
  req_url_query(limit = 5, sortBy = "price", order = "desc") |>
  req_perform()

products <- resp_body_json(resp)
resp_status(resp)                       # 200
length(products)                        # 5, most expensive first
resp_header(resp, "x-total-count")      # "30" โ€” total across all pages

Every list endpoint sends X-Total-Count, so you always know how much is left without a second call.

3. Straight to a data.frame

For flat records, jsonlite::fromJSON() simplifies to a typed data.frame in one line โ€” note price comes back num and inStock comes back logi, not strings:

library(jsonlite)
df <- fromJSON("https://mockbird.mockbird.workers.dev/m/demo/products?limit=100")
str(df, max.level = 1)
# 'data.frame': 30 obs. of  8 variables:
#  $ id      : int  1 2 3 4 5 ...
#  $ name    : chr  "World End" ...
#  $ price   : num  874 114 133 346 527 ...
#  $ inStock : logi  TRUE FALSE FALSE ...

When you'd rather exercise a real pagination loop (the thing that breaks in production), httr2 + X-Total-Count:

fetch_all <- function(url, page_size = 10) {
  page <- 1; out <- list()
  repeat {
    resp <- request(url) |>
      req_url_query(page = page, limit = page_size) |>
      req_perform()
    rows <- resp_body_json(resp, simplifyVector = TRUE)
    if (length(rows) == 0 || nrow(rows) == 0) break
    out[[page]] <- rows
    total <- as.integer(resp_header(resp, "x-total-count"))
    if (page * page_size >= total) break
    page <- page + 1
  }
  do.call(rbind, out)
}

df <- fetch_all("https://mockbird.mockbird.workers.dev/m/demo/products")
# 3 pages fetched -> 30 rows

4. The other direction: any data.frame โ†’ a hosted REST API

This is the part R people don't expect. You already have data.frames; Mockbird imports CSV. So one function turns any data.frame into a live, typed REST API โ€” here's mtcars:

library(httr2)

df_to_mock_api <- function(df, resource = "rows") {
  csv <- paste(capture.output(write.csv(df, row.names = FALSE)), collapse = "\n")
  request("https://mockbird.mockbird.workers.dev/api/projects/import") |>
    req_url_query(resource = resource) |>
    req_body_raw(csv, type = "text/csv") |>
    req_perform() |>
    resp_body_json()
}

api <- df_to_mock_api(transform(mtcars, car = rownames(mtcars)), "cars")
api$id        # your project id, e.g. "jhp288smkx"
api$adminKey  # save this โ€” it's the project's admin credential

Columns are typed on import (numbers stay numbers), which means range filters work immediately โ€” no server code, no plumber, nothing to deploy:

curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/cars?mpg_gte=30&select=car,mpg"
# [ {"id":18,"car":"Fiat 128","mpg":32.4},
#   {"id":19,"car":"Honda Civic","mpg":30.4},
#   {"id":20,"car":"Toyota Corolla","mpg":33.9},
#   {"id":28,"car":"Lotus Europa","mpg":30.4} ]

Share that URL with a colleague, point a prototype frontend at it, or use it as the fixture your Shiny app develops against. (Anonymous projects are capped at 10/day per IP โ€” limits โ€” and you can delete one with a single authenticated DELETE.)

5. Writes are real

Unlike read-only fake APIs, POSTs persist โ€” so you can test the full round-trip:

base <- "https://mockbird.mockbird.workers.dev/m/YOUR_ID"

created <- request(base) |>
  req_url_path_append("cars") |>
  req_body_json(list(car = "Citroen 2CV", mpg = 47, cyl = 2, hp = 29)) |>
  req_perform() |>
  resp_body_json()
created$id      # 33

back <- request(base) |>
  req_url_path_append("cars", created$id) |>
  req_perform() |>
  resp_body_json()
back$car        # "Citroen 2CV" โ€” it's really there

6. Failure drills unit tests can't give you

This is where a mock with simulation flags earns its keep: real HTTP failures, deterministic, from query params. First, prove your retry logic recovers โ€” mock_seq=503,503,200 serves exactly that sequence (keyed, so parallel runs don't interfere):

key <- paste0("run-", format(Sys.time(), "%H%M%S"))

resp <- request("https://mockbird.mockbird.workers.dev/m/demo/products") |>
  req_url_query(limit = 1, mock_seq = "503,503,200", mock_seq_key = key) |>
  req_retry(max_tries = 4) |>
  req_perform()
# Waiting 3s for retry backoff โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– 
# Waiting 3s for retry backoff โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– โ– 
resp_status(resp)   # 200 โ€” after two real 503s and real backoff

The gotcha this drill catches: httr2's req_retry() only treats 429 and 503 as transient by default. Point the same code at mock_seq=500,500,200 and it fails immediately โ€” no retry:

# default req_retry() does NOT retry a 500:
#   Error: HTTP 500 Internal Server Error.

# tell httr2 which statuses are transient for YOUR upstream API:
resp <- request("https://mockbird.mockbird.workers.dev/m/demo/products") |>
  req_url_query(limit = 1, mock_seq = "500,500,200", mock_seq_key = key) |>
  req_retry(max_tries = 4,
            is_transient = function(resp) resp_status(resp) %in% c(429, 500, 502, 503)) |>
  req_perform()
resp_status(resp)   # 200 โ€” now it recovers

Rate limits, with real countdown headers and a real Retry-After โ€” mock_ratelimit=3 allows 3 requests per rolling minute per key:

key <- paste0("rl-", format(Sys.time(), "%H%M%S"))
req <- request("https://mockbird.mockbird.workers.dev/m/demo/products") |>
  req_url_query(limit = 1, mock_ratelimit = 3, mock_ratelimit_key = key)

# call 1 -> 200  remaining: 2  retry-after: -
# call 2 -> 200  remaining: 1  retry-after: -
# call 3 -> 200  remaining: 0  retry-after: -
# call 4 -> 429  remaining: 0  retry-after: 11

httr2 treats 429 as transient and sleeps for Retry-After automatically โ€” add req_retry() and the fourth call quietly waits out the window instead of crashing your script. Finally, slowness โ€” does your code give up gracefully?

slow <- request("https://mockbird.mockbird.workers.dev/m/demo/products") |>
  req_url_query(limit = 1, mock_delay = 3000) |>   # server answers after 3s
  req_timeout(2)                                    # you give up after 2s

tryCatch(req_perform(slow), error = function(e) conditionMessage(e))
# "Failed to perform HTTP request."
#   Caused by: Timeout was reached ... after 2002 milliseconds with 0 bytes received

7. Auth flows without an auth provider

The demo (and any project) has JWT login endpoints โ€” any email/password pair works, and the token is a real signed JWT:

login <- request("https://mockbird.mockbird.workers.dev/m/demo/auth/login") |>
  req_body_json(list(email = "ana@example.com", password = "anything")) |>
  req_perform() |>
  resp_body_json()

me <- request("https://mockbird.mockbird.workers.dev/m/demo/auth/me") |>
  req_auth_bearer_token(login$token) |>
  req_perform() |>
  resp_body_json()
me$user$email   # "ana@example.com"

Flip a project to protected mode and every endpoint requires the Bearer token โ€” so your script's 401-handling path gets exercised too, including token expiry (mint one with expiresIn = 5 and watch your refresh logic actually run).

8. Shiny against the mock

Because the mock is a public https URL with open CORS, it works identically from runApp() on your laptop and from a deployed shinyapps.io app โ€” no "works locally, 404s in production" surprises:

library(shiny)
library(httr2)

ui <- fluidPage(
  titlePanel("Products (mock API)"),
  selectInput("cat", "Category",
              c("all", "electronics", "books", "toys", "home", "sports")),
  tableOutput("table")
)

server <- function(input, output, session) {
  products <- reactive({
    req <- request("https://mockbird.mockbird.workers.dev/m/demo/products") |>
      req_url_query(limit = 100)
    if (input$cat != "all") req <- req |> req_url_query(category = input$cat)
    req_perform(req) |> resp_body_json(simplifyVector = TRUE)
  })
  output$table <- renderTable(products()[, c("name", "price", "category")])
}

shinyApp(ui, server)

(Verified headlessly with shiny::testServer(): the reactive returns all 30 rows on "all" and narrows to a single category when the input changes.) Swapping to the real API later is a one-line base-URL change โ€” which is the whole point of developing against a mock that behaves like a real server.

9. Honest comparison: the R-native options

httptest2 / vcr + webmockrwebfakesplumberhosted mock (Mockbird)
Offline / CRAN-check safeโœ” โ€” designed for itโœ” (local process)โœ” if localโœ— โ€” needs network
Real HTTP through curlโœ— (intercepted in-process)โœ”โœ”โœ”
Reachable from shinyapps.io / a teammate / CIโœ—โœ— (localhost)only if you host itโœ” โ€” it's a URL
Setuptest-file scaffoldingwrite app in Rwrite + run + host APIone curl (or one function, ยง4)
Inject 503s / 429s / delays deterministicallystub them by handwrite handlerswrite handlersone query param
Persistent CRUD + filters + pagination for freeโœ—โœ—you build itโœ”

Be clear-eyed: for unit tests of an API-wrapper package โ€” especially one headed to CRAN, where checks must pass offline โ€” httptest2 or vcr record/replay real responses and are the right tool; r-lib's webfakes gives you a fully local fake server with zero network flake. Use the hosted mock for the things those can't do: integration tests through real curl, Shiny apps that must fetch from wherever they're deployed, failure drills against real status codes, and sharing one dataset across a team.

10. Useful extras

Related: CSV โ†’ REST API, mock APIs for Python, mock APIs for PyQt/PySide, mock APIs for Streamlit, mock APIs for Gradio, testing loading and error states, mock JWT auth, deterministic test data with snapshots, and free mock-API tools compared.

Verification: every R snippet on this page was run verbatim with R 4.3.3 + httr2 1.0.0 + jsonlite + shiny 1.8 (Linux) against production on 22 Sep 2026 โ€” the printed outputs shown are from those runs. The mtcars section ran with the exact function shown against a real project (since deleted); the Shiny server logic ran headlessly via shiny::testServer(). Demo data reseeds daily, so names, prices and per-category counts will differ when you run it. If a snippet here doesn't work in your R session, that's a bug: tell us.