HTTP and Web APIs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. Most of this page is base R (the base, utils and tools packages that ship with R): download.file(), url() connections, curlGetHeaders(), URLencode(). The rest uses add-on packages, each labelled as a package where it appears and each checked at the version shown: httr2 1.3.0 (the request and response client), jsonlite 2.0.0 (JSON, which httr2 uses), curl 8.1.0 (the R binding to libcurl underneath httr2), plumber 1.3.3 with httpuv 1.6.17 under it (only to build the local test server) and callr 3.8.0 (only to start that server in a background R process). The older httr and RCurl packages are described in the older-tutorials section and are not run, and the lintr 3.4.0 default linters were used only to check the scripts. Reading the web is the most network-dependent thing R does, and the least reproducible in a document, so this page is built around one rule: nothing on it depends on the internet. Every request that is actually sent goes to a small local server on 127.0.0.1 that the script itself starts and stops; the other examples are dry runs that print the request instead of sending it, mocked responses that never leave R, or file:// URLs.

Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters. The names books, loans and the other objects of the running library-shelf scenario come from the section index: a script that needs them starts with source("scenario.R"), and the local server in these examples serves the books data frame and the books.csv file of that scenario. Where a message contains quotation marks or a bullet, it is the typographic character R prints in a UTF-8 locale. A few values change from run to run (a Date: header, a number of milliseconds in a timeout message): the scripts remove or mask them and say so, so that what you see here is what you get. Dry runs and mocked examples use https://api.example.com/... as a placeholder host that is never contacted; the placeholder is the only host on the page that is not real.

This page belongs to the data import and export group. Reading a CSV from a URL is a case of the connections explained in Connections and the File System and the readers in Reading and Writing Text Data; JSON is covered by Serialization: RDS, RData and JSON. Serving an API of your own with plumber, and Shiny, are the subject of Shiny, plumber and Web APIs; here plumber appears only as a way to have something to talk to. Error classes and tryCatch() are explained in Conditions and Error Handling.

How R talks to the web

An HTTP exchange is small. The client sends a request: a method (GET to read, POST to create, PUT or PATCH to change, DELETE, and HEAD to ask for the headers only), a URL, headers (Accept, Authorization, User-Agent) and sometimes a body. The server answers with a response: a status code, headers (Content-Type, Retry-After) and usually a body. Everything an R package for the web does is some way of building that request, sending it and reading that response. The status codes that matter most are in this table.

Status Meaning What a client does

200 OK

the request worked and the body is the result

read the body

201 Created

a POST created something, often with a Location header

read the Location header or the returned body

301, 302

the resource moved

follow the Location header (libcurl does by default)

400 Bad Request

the server did not understand the request

fix the request; do not retry

401 Unauthorized

credentials are missing or wrong

send or refresh credentials

403 Forbidden

credentials are valid but not allowed

do not retry

404 Not Found

there is nothing at that URL

handle it as "no such thing"

429 Too Many Requests

a rate limit was hit, often with Retry-After

wait and retry

500, 503

the server failed or is overloaded

retry a few times, then give up

R offers four levels for doing this, and a script should pick the lowest one that does the job. The table is the map for the rest of the page.

Level Functions Use it for

read a URL like a file

readLines(url(...)), read.csv("https://..."), scan()

a text or CSV resource that needs no headers and no authentication

download a file

download.file() (base R), curl::curl_download()

saving a file to disk, including binary files and several files at once

inspect a URL

curlGetHeaders() (base R)

the status and headers, without the body

talk to an API

httr2 (request(), req_perform() and the req_*() and resp_*() families)

methods other than GET, JSON bodies, authentication, error bodies, retries, rate limits, paging, caching

R Data Import/Export says it in two sentences. Base R has "some limited facilities" to exchange data across network connections, and download.file() reads a file from a Web resource via FTP or HTTP (including HTTPS) and writes it to a file; often this can be avoided, because functions such as read.table() and scan() can read directly from a URL, either explicitly through url() or implicitly by being given a URL as the file argument (R Data Import/Export: Using download.file). The same chapter has a section on reading from sockets, with the advice to use socket connections (socketConnection()) in new projects rather than the earlier make.socket() family (R Data Import/Export: Reading from sockets). Sockets are the layer below HTTP and are rarely what a data script needs; this page stays at the HTTP level.

Underneath, all of it is libcurl, the C library that R was built with for its own functions and that the curl package binds for httr2. This is why the same things go wrong everywhere in the same way: a proxy, a certificate that cannot be verified, a timeout.

A local test server for the examples

To show a client at work without the internet, the examples need something to talk to. The page builds a miniature "shelf" web service with plumber (plumber package documentation: pr). pr() makes an empty router; pr_get(), pr_post() and the general pr_handle() add routes, each a path and a handler function; pr_set_serializer() chooses how return values become a body; pr_run() starts the server. A handler can take the arguments req (the request: headers such as req$HTTP_AUTHORIZATION, a parsed JSON body in req$body) and res (the response: res$status, res$setHeader()), the query-string parameters by name (limit = "5" arrives as a character string), and the parts of a path written in angle brackets: "/books/<isbn>" passes isbn. A returned list or data frame is turned into JSON; serializer_unboxed_json() writes length-one values as JSON scalars instead of one-element arrays, which is what most APIs do, and serializer_text(type = "text/csv") sends a string as CSV. When a route throws an error, or no route matches, plumber answers with a JSON body of its own: {"error":"404 - Resource Not Found"} and {"error":"500 - Internal server error"}.

pr_run() does not return: it serves requests until it is interrupted. A script cannot start the server and then act as its client in the same R process, because the client call blocks R while the server is waiting for R to serve (tried while preparing this page: an httpuv server started in the same process and a blocking curl request to it timed out, because a blocked R cannot run the server’s event loop; a non-blocking client plus a loop calling httpuv::service() does work, but it is far more awkward). So the examples start the server in a second R process with callr, and all of it lives inside the one script: start, wait until it answers, run the client code, stop. The labelled "local test server" function below is the pattern every later script repeats with different routes.

library(httr2)
source("scenario.R")

# A local test server: a plumber API that serves the shelf books as JSON.
shelf_api <- function(books, port) {
  plumber::pr() |>
    plumber::pr_set_serializer(plumber::serializer_unboxed_json()) |>
    plumber::pr_get("/ping", \() list(ok = TRUE)) |>
    plumber::pr_get("/books", \() books[, c("title", "isbn", "year")]) |>
    plumber::pr_run(host = "127.0.0.1", port = port, docs = FALSE, quiet = TRUE)
}

# pr_run() blocks, so run the API in a background R process.
srv <- callr::r_bg(shelf_api, args = list(books = books, port = 8765L))
srv$is_alive()
#> [1] TRUE
base <- "http://127.0.0.1:8765"

# Wait until the server answers: retry connection failures, not only 429/503.
ping <- request(base) |>
  req_url_path_append("ping") |>
  req_retry(max_tries = 20, retry_on_failure = TRUE, backoff = \(i) 0.25) |>
  req_perform()
ping
#> <httr2_response>
#> GET http://127.0.0.1:8765/ping
#> Status: 200 OK
#> Content-Type: application/json
#> Body: In memory (11 bytes)
resp_body_json(ping)
#> $ok
#> [1] TRUE
#>

request(base) |>
  req_url_path_append("books") |>
  req_perform() |>
  resp_body_json(simplifyVector = TRUE) |>
  head(3)
#>                  title              isbn year
#> 1     The Dispossessed 978-0-306-40615-7 1974
#> 2  Pride and Prejudice 978-0-14-143951-8 1813
#> 3   the quiet streets      9780001000100 2019

# Always stop the server before the script ends.
invisible(srv$kill())
srv$is_alive()
#> [1] FALSE

Four details of this script matter for every later one.

  • The function is self-contained. callr documents that the function passed to r_bg() has its environment set to the global environment before it is sent to the child process, so it should only refer to other functions and variables from other packages "using the :: notation". That is why shelf_api() takes books and port as arguments, passed in args, and calls plumber::pr() rather than assuming library(plumber). A closure over books in the parent session finds no books in the child, and every request answers 500: this was the first error met while writing these examples.

  • The port is fixed (8765) and the host is 127.0.0.1, the loopback address: the server is reachable only from the same machine. Each script stops its server before it ends, so scripts can run one after another on the same port.

  • The wait is a retry. The server needs a moment to start, and a request sent too early fails with "Couldn’t connect to server". req_retry(retry_on_failure = TRUE) treats that low-level failure as transient and tries again after backoff seconds, up to max_tries attempts (it is explained in the section on retries below). The base R scripts use a short loop around curlGetHeaders(), which also fails until the port is open.

  • The server is stopped explicitly. srv$kill() stops the background process and srv$is_alive() confirms it. A callr process is also stopped when its R object is garbage collected or the parent R session ends, so an error in the middle of a script does not leave the server behind; do not rely on that, and inside a function use on.exit(srv$kill()).

The server could be written with httpuv directly (httpuv package documentation: startServer): plumber is a routing layer on top of it. A different approach for the tests of a package is to mock the responses instead of serving them; httr2 supports it and the last example script shows it. From here on, each script starts its own server with the routes it needs. The routes are part of the example (a real API has its own), and a script shows only the routes it uses.

Base R: download.file()

What it does and its arguments

download.file() copies the resource at a URL to a local file. Its signature in R 4.6.1 is

download.file(url, destfile, method, quiet = FALSE, mode = "w",
              cacheOK = TRUE, extra = getOption("download.file.extra"),
              headers = NULL, ...)

and its arguments are:

  • url is a character string naming the resource, starting with a scheme such as http://, https:// or file://; with the "libcurl" method it may be a longer vector. destfile is the path to write, tilde-expanded, a vector of the same length when url is.

  • method is one of "internal", "libcurl", "wget", "curl", "wininet" (Windows only) or "auto", and when it is missing it comes from getOption("download.file.method"). With "auto" (the default when nothing is set) the "internal" method is used for file:// URLs and "libcurl" for all others. Today "internal" supports only the file:// scheme. The "wget" and "curl" methods run an external program, which must be installed and on the search path; they block R until they finish and exist mainly for historical compatibility. The help page’s advice is that setting the method "should be left to the end user", so a script written for others should not hard-code it unless it needs a feature, several files at once for instance, that "libcurl" provides.

  • quiet = TRUE suppresses the status messages and the progress bar. With the default FALSE a download prints trying URL ..., the content type and length, a text progress bar and downloaded N bytes; they are messages, written to standard error.

  • mode is how the file is written: "w", "wb", "a" or "ab". The choice matters only on Windows, where a text-mode transfer changes \n line endings to \r\n and so corrupts a binary file; the help page says the problems of a text transfer "will only be seen on Windows", so code written on Linux or macOS must still use mode = "wb" for everything that is not text. (On Windows, when mode is missing and the URL ends in .gz, .bz2, .xz, .tgz, .zip, .jar, .rda, .rds, .RData or .pdf, R chooses "wb" itself.)

  • cacheOK = FALSE asks for a copy from the site rather than from an intermediate cache, for http:// and https:// URLs.

  • extra is for the "wget" and "curl" methods only. headers is a named character vector of extra HTTP headers for http:// and https:// URLs (added in R 3.6.0); the User-Agent header comes from the HTTPUserAgent option and is sent first.

  • The value is an invisible integer: 0 for success and non-zero for failure. When several files are downloaded at once and at least one succeeds, it is 0 with an attribute "retvals" holding the code of each file.

The timeout for many parts of the transfer comes from the option timeout, in seconds, default 60, which can be set for the whole session with the environment variable R_DEFAULT_INTERNET_TIMEOUT (R 4.0.3 NEWS). The documentation points out that 60 seconds is often insufficient for downloads of 50 MB or more, and that a package should write options(timeout = max(300, getOption("timeout"))) rather than lower the value. The timeout is for a response that does not arrive, not for the whole operation (R documentation: connections).

libcurl, the default method

The "libcurl" method is the one you will use. It uses the external library of that name; capabilities("libcurl") tells whether R was built with it, and libcurlVersion() reports the version in use and the URL schemes it supports (R documentation: libcurlVersion). It was added as an optional method in R 3.2.0, became the default on Unix-alikes in R 3.4.0 for everything except file:// URLs, and became the default on Windows in R 4.2.0, where it had been optional until then; R 4.2.0 also made libcurl required for building R from source and reduced the "internal" method to file:// only. The section "Version notes" has the table.

The first example starts the local server, downloads the book list with method = "libcurl" and mode = "wb", and checks that the downloaded file reads back as the books data frame it came from. Its later parts download several files at once, read a file:// URL and use the curl package.

source("scenario.R")

capabilities("libcurl")
#> libcurl
#>    TRUE

# A local test server: plumber serving the shelf CSV files. HEAD is allowed
# too, because curlGetHeaders() sends a HEAD request.
shelf_api <- function(files, port) {
  plumber::pr() |>
    plumber::pr_handle(
      c("GET", "HEAD"), "/csv/<name>",
      \(res, name) {
        if (!name %in% names(files)) {
          res$status <- 404L
          return("no such file")
        }
        files[[name]]
      },
      serializer = plumber::serializer_text(type = "text/csv")
    ) |>
    plumber::pr_run(host = "127.0.0.1", port = port, docs = FALSE, quiet = TRUE)
}

files <- list(
  books = readChar("books.csv", file.size("books.csv")),
  loans = readChar("loans.csv", file.size("loans.csv"))
)
srv <- callr::r_bg(shelf_api, args = list(files = files, port = 8765L))
base <- "http://127.0.0.1:8765"

# curlGetHeaders() fails until the port is open: a handy readiness probe.
for (i in seq_len(40L)) {
  probe <- try(curlGetHeaders(paste0(base, "/csv/books")), silent = TRUE)
  if (!inherits(probe, "try-error")) break
  Sys.sleep(0.25)
}

dest <- tempfile(fileext = ".csv")
status <- download.file(
  paste0(base, "/csv/books"), dest,
  method = "libcurl", mode = "wb", quiet = TRUE
)
status
#> [1] 0
file.exists(dest)
#> [1] TRUE
identical(read.csv(dest), books)
#> [1] TRUE
unname(tools::md5sum(dest) == tools::md5sum("books.csv"))
#> [1] TRUE

# With quiet = FALSE the progress messages go to the message stream.
download.file(paste0(base, "/csv/books"), dest, mode = "wb")
#> trying URL 'http://127.0.0.1:8765/csv/books'
#> Content type 'text/csv' length 1187 bytes
#> ==================================================
#> downloaded 1187 bytes

# Several files at once: url and destfile are vectors (libcurl only).
dests <- c(tempfile(), tempfile())
urls <- paste0(base, "/csv/", c("books", "loans"))
download.file(urls, dests, method = "libcurl", quiet = TRUE)
file.size(dests) == file.size(c("books.csv", "loans.csv"))
#> [1] TRUE TRUE

# A file:// URL is read by R's own code, with an empty host field.
local_url <- paste0("file://", normalizePath("books.csv"))
substr(local_url, 1L, 8L)
#> [1] "file:///"
dest_file <- tempfile()
download.file(local_url, dest_file, quiet = TRUE)
identical(readLines(dest_file), readLines("books.csv"))
#> [1] TRUE

# The curl package: curl_download() is a simpler, cross-platform equivalent.
dest_curl <- tempfile()
invisible(curl::curl_download(paste0(base, "/csv/books"), dest_curl))
identical(readLines(dest_curl), readLines("books.csv"))
#> [1] TRUE

invisible(srv$kill())

Reading the output from the top:

  • capabilities("libcurl") is TRUE.

  • The first download returned 0 and wrote a file, which read.csv() turns back into an identical books and whose MD5 sum equals that of books.csv: the server delivered the bytes unchanged. The path of the temporary file is never printed, because tempfile() returns a path under the session’s temporary directory that differs from run to run; the script prints file.exists() and a comparison instead.

  • With quiet = FALSE the progress messages appear. They are written to standard error, so in a script’s merged output they can come before the output of the call that produced them; they are shown under their call.

  • A vector of URLs and a vector of destinations download several files at once. Only the "libcurl" method does this, and only when it is requested explicitly (not through "auto"). In R 4.5.0 install.packages() and download.packages() started using it to download packages simultaneously, and download.file() reports the outcome for individual files (R NEWS).

  • A file:// URL has an empty host field, so a local file is file:// followed by an absolute path that starts with /: three slashes in all on a Unix-alike. The script builds it with paste0("file://", normalizePath("books.csv")) and prints only its first eight characters, because the rest is the absolute path of this machine. R handles file:// URLs with its own code whatever the method is, does not decode percent-encoded characters in them (call URLdecode() if needed), and cannot express a relative path with them.

  • curl::curl_download(url, destfile, quiet = TRUE, mode = "wb", handle = new_handle()) is the same job in the curl package (curl package documentation: curl_download). It uses the same libcurl, is quiet and binary by default, and behaves the same on every platform. Its handle argument takes a curl::new_handle() object (curl package documentation: handle) holding libcurl options, headers and cookies.

When the download fails

download.file() is not all-or-nothing: in some cases a non-zero status is returned without any error, in others a warning comes first and an error after it. The help page’s advice is firm: a package or script that uses it must check the return value, because a download can fail with a non-zero status and no R error. The next script produces each failure against the local server: a 404, several downloads of which one fails, a status check, and a server that is gone. The helper diagnose() evaluates an expression and returns the warnings it raised and the message of the error it ended with (conditions are explained in Conditions and Error Handling).

source("scenario.R")

shelf_api <- function(files, port) {
  plumber::pr() |>
    plumber::pr_handle(
      c("GET", "HEAD"), "/csv/<name>",
      \(res, name) {
        if (!name %in% names(files)) {
          res$status <- 404L
          return("no such file")
        }
        files[[name]]
      },
      serializer = plumber::serializer_text(type = "text/csv")
    ) |>
    plumber::pr_run(host = "127.0.0.1", port = port, docs = FALSE, quiet = TRUE)
}

files <- list(books = readChar("books.csv", file.size("books.csv")))
srv <- callr::r_bg(shelf_api, args = list(files = files, port = 8765L))
base <- "http://127.0.0.1:8765"
for (i in seq_len(40L)) {
  probe <- try(curlGetHeaders(paste0(base, "/csv/books")), silent = TRUE)
  if (!inherits(probe, "try-error")) break
  Sys.sleep(0.25)
}

# Run an expression and return its warnings and its error message.
diagnose <- function(expr) {
  warnings <- character()
  error <- withCallingHandlers(
    tryCatch({
      expr
      NA_character_
    }, error = \(e) conditionMessage(e)),
    warning = \(w) {
      warnings <<- c(warnings, conditionMessage(w))
      invokeRestart("muffleWarning")
    }
  )
  list(warnings = warnings, error = error)
}

# 1. The server answers 404: two warnings, then an error.
dest <- tempfile()
diagnose(download.file(paste0(base, "/csv/shelves"), dest, quiet = TRUE))
#> $warnings
#> [1] "downloaded length 0 != reported length 12"
#> [2] "cannot open URL 'http://127.0.0.1:8765/csv/shelves': HTTP status was '404 Not Found'"
#>
#> $error
#> [1] "cannot open URL 'http://127.0.0.1:8765/csv/shelves'"
#>
file.exists(dest)
#> [1] FALSE

# 2. One of several downloads fails: no error, but attribute "retvals".
dests <- c(tempfile(), tempfile())
urls <- paste0(base, "/csv/", c("books", "shelves"))
status <- suppressWarnings(
  download.file(urls, dests, method = "libcurl", quiet = TRUE)
)
unclass(status)
#> [1] 0
#> attr(,"retvals")
#> [1] 0 1
file.exists(dests)
#> [1]  TRUE FALSE

# 3. In a script, check the return value (a non-zero status is not an error).
ok <- download.file(paste0(base, "/csv/books"), dests[[1L]], quiet = TRUE)
if (ok != 0L) stop("download failed", call. = FALSE)
nrow(read.csv(dests[[1L]]))
#> [1] 16

# 4. The server is gone: R reports a libcurl error code.
invisible(srv$kill())
res <- diagnose(download.file(paste0(base, "/csv/books"), dest, quiet = TRUE))
res$warnings
#> [1] "URL 'http://127.0.0.1:8765/csv/books': status was 'Couldn't connect to server'"
res$error
#> [1] "cannot open URL 'http://127.0.0.1:8765/csv/books'"
res <- diagnose(readLines(url(paste0(base, "/csv/books"))))
res$warnings
#> [1] "URL 'http://127.0.0.1:8765/csv/books': status was 'Couldn't connect to server'"
res$error
#> [1] "cannot open the connection to 'http://127.0.0.1:8765/csv/books'"
err <- tryCatch(curlGetHeaders(paste0(base, "/csv/books")), error = identity)
sub("after [0-9]+ ms", "after N ms", conditionMessage(err))
#> [1] "libcurl error code 7:\n\tFailed to connect to 127.0.0.1 port 8765 after N ms: Couldn't connect to server\n"

# 5. The timeout option: seconds without a response, default 60.
getOption("timeout")
#> [1] 60

What the output shows:

  • The 404 produced two warnings and then an error. The first warning, downloaded length 0 != reported length 12, is R noticing that the server announced a 12-byte body (this server’s "no such file" text) that was not stored; the second, cannot open URL '...': HTTP status was '404 Not Found', is the cause; and the error, cannot open URL '...', carries only the URL. The destination file does not exist afterwards: the "internal", "wininet" and "libcurl" methods remove the file when the URL is unavailable (unless mode is an append mode, when it is left unchanged).

  • With several URLs, one failure is not an error. The call returned 0 with retvals of 0 1 and two warnings (cannot open URL ... and some files were not downloaded), and the good file was written. A script that downloads several files has to read attr(status, "retvals").

  • A server that is down gives a warning that quotes libcurl’s text, status was 'Couldn't connect to server', followed by an error: cannot open URL '...' from download.file() and cannot open the connection to '...' from readLines(url()). curlGetHeaders() raises a single error whose text starts with libcurl error code 7:; the script masks the number of milliseconds in it (after N ms) because it varies.

  • The timeout option is 60 seconds unless it was changed.

R 4.3.0 added a helpful error message when options(download.file.method) holds an invalid value, and since R 4.5.2 install.packages() and download.packages() again consult that option when the method is not given (R NEWS).

Credentials

A URL may carry credentials, https://username:password@machine/..., but the help page does not recommend it: with HTTP they are sent in plain text, and even with HTTPS they may appear in server logs. The alternatives are the Authorization entry of the headers argument (R 3.6.0 and later) and, with the "libcurl" method, a netrc file named by the option netrc: R 4.6.0 added credentials for basic HTTP authentication in a netrc file, whose path is options("netrc") and can be set from the environment variable R_DEFAULT_NETRC; the file should not be readable by other users. Never put a password or a token in a script. Read it with Sys.getenv() from the environment, from a .Renviron file (Sessions, Workspaces and Startup) or from a secrets store. For anything beyond one header, httr2 (below) is the better tool.

URL connections and curlGetHeaders()

Reading from a URL with url()

url() creates a connection to a resource, and file() and every reader that takes a file name also accept a URL string. For url() the description is a complete URL including the scheme (http://, https://, ftp:// or file://). The signature is url(description, open = "", blocking = TRUE, encoding = getOption("encoding"), method = getOption("url.method", "default"), headers = NULL): the method "default" is "internal" for file:// URLs and "libcurl" for all others, headers is a named character vector of HTTP headers (the User-Agent comes from the HTTPUserAgent option), and all the methods attempt to follow redirections. The help page notes that "server-side cached data is always accepted", so there is no cacheOK here. Connections in general are explained in Connections and the File System. What is special about url() is that blocking operations on an HTTP connection are subject to the timeout option, which is set when the connection is opened.

In the script below, a function that opens a connection by itself (readLines(url(...)), read.csv(url(...))) closes it again, which is the form to prefer. A connection that is created, opened and read in pieces must be closed with close(), ideally registered right after creation with on.exit(close(con)) inside a function.

source("scenario.R")

# A local test server: CSV files, a JSON list of books, a header echo and a
# redirect. HEAD is allowed because curlGetHeaders() sends a HEAD request.
shelf_api <- function(books, csv, port) {
  plumber::pr() |>
    plumber::pr_handle(
      c("GET", "HEAD"), "/books.csv", \() csv,
      serializer = plumber::serializer_text(type = "text/csv")
    ) |>
    plumber::pr_handle(c("GET", "HEAD"), "/old-books.csv", \(res) {
      res$status <- 302L
      res$setHeader("Location", "/books.csv")
      ""
    }) |>
    plumber::pr_get("/books", \() books[, c("title", "isbn", "year")]) |>
    plumber::pr_get("/whoami", \(req) {
      list(client = req$HTTP_X_SHELF_CLIENT)
    }, serializer = plumber::serializer_unboxed_json()) |>
    plumber::pr_run(host = "127.0.0.1", port = port, docs = FALSE, quiet = TRUE)
}

csv <- readChar("books.csv", file.size("books.csv"))
srv <- callr::r_bg(
  shelf_api,
  args = list(books = books, csv = csv, port = 8765L)
)
base <- "http://127.0.0.1:8765"
for (i in seq_len(40L)) {
  probe <- try(curlGetHeaders(paste0(base, "/books.csv")), silent = TRUE)
  if (!inherits(probe, "try-error")) break
  Sys.sleep(0.25)
}

# readLines() opens the connection, reads, and closes it again.
readLines(url(paste0(base, "/books.csv")), n = 3L)
#> [1] "\"title\",\"author\",\"isbn\",\"year\",\"genre\",\"pages\""
#> [2] "\"The Dispossessed\",\"Ursula K. Le Guin\",\"978-0-306-40615-7\",1974,\"Science fiction\",387"
#> [3] "\"Pride and Prejudice\",\"Jane Austen\",\"978-0-14-143951-8\",1813,\"Classic\",432"

# An explicit connection: create, open, read in pieces, close.
con <- url(paste0(base, "/books.csv"))
summary(con)[c("class", "opened")]
#> $class
#> [1] "url-libcurl"
#>
#> $opened
#> [1] "closed"
#>
open(con, "r")
readLines(con, n = 1L)
#> [1] "\"title\",\"author\",\"isbn\",\"year\",\"genre\",\"pages\""
readLines(con, n = 1L)
#> [1] "\"The Dispossessed\",\"Ursula K. Le Guin\",\"978-0-306-40615-7\",1974,\"Science fiction\",387"
close(con)

# read.csv() takes a URL string directly: it makes the url() connection.
shelf <- read.csv(paste0(base, "/books.csv"))
identical(shelf, books)
#> [1] TRUE

# Extra request headers for the connection (R 3.6.0 and later).
whoami <- url(paste0(base, "/whoami"), headers = c("X-Shelf-Client" = "docs"))
readLines(whoami, warn = FALSE)
#> [1] "{\"client\":\"docs\"}"

# JSON straight from a URL with jsonlite.
head(jsonlite::fromJSON(paste0(base, "/books")), 2L)
#>                 title              isbn year
#> 1    The Dispossessed 978-0-306-40615-7 1974
#> 2 Pride and Prejudice 978-0-14-143951-8 1813

# curlGetHeaders(): status and header lines, following redirects by default.
h <- curlGetHeaders(paste0(base, "/books.csv"))
attr(h, "status")
#> [1] 200
h <- trimws(h[!grepl("^Date:", h)])
h[nzchar(h)]
#> [1] "HTTP/1.1 200 OK"        "Content-Type: text/csv" "Content-Length: 1187"

h <- curlGetHeaders(paste0(base, "/old-books.csv"))
attr(h, "status")
#> [1] 200
sum(grepl("^HTTP/", h))
#> [1] 2
trimws(h[grepl("^(HTTP|Location)", h)])
#> [1] "HTTP/1.1 302 Found"   "Location: /books.csv" "HTTP/1.1 200 OK"

h <- curlGetHeaders(paste0(base, "/old-books.csv"), redirect = FALSE)
attr(h, "status")
#> [1] 302
trimws(h[grepl("^(HTTP|Location)", h)])
#> [1] "HTTP/1.1 302 Found"   "Location: /books.csv"

h <- curlGetHeaders(paste0(base, "/missing"))
attr(h, "status")
#> [1] 404
trimws(h[1L])
#> [1] "HTTP/1.1 404 Not Found"

invisible(srv$kill())

Notes on this script:

  • The class of a url() connection under the default method is "url-libcurl", and it starts "closed". readLines(con, n = 1L) on an open connection continues where the previous read stopped, which is how a large resource is read in pieces.

  • read.csv(url_string) and read.csv(url(url_string)) give the same data frame; the first is the usual way to write it. The same holds for scan(), readLines() and read.table().

  • The headers argument of url() (R 3.6.0 and later) is how a request header reaches the server; the local /whoami route echoes the custom header back as JSON. readLines() of a response with no final newline warns "incomplete final line"; warn = FALSE silences it.

  • jsonlite::fromJSON() accepts a URL directly and returns a data frame for a JSON array of records. It is only suitable for a plain GET; for anything else, parse the body after an httr2 call.

Looking at the headers: curlGetHeaders()

curlGetHeaders(url, redirect = TRUE, verify = TRUE, timeout = 0L, TLS = "") retrieves the headers for a URL without the body. The help page says it reports what curl -I -L or curl -I would report, so it sends a HEAD request, and the server must accept one. A route declared with pr_get() alone answers HEAD with 405 Method Not Allowed and an empty Allow: header (seen with the checked version of plumber), so the local server declares its routes for GET and HEAD with pr_handle(c("GET", "HEAD"), ...). HTTP servers are allowed to refuse a HEAD and some do; the help page says it results in a status of 405.

The value is a character vector of header lines, each ending in \r\n and the last one blank, with an integer attribute "status", the last-received status code. With redirections, which are followed by default, up to 20, it contains the headers of every URL visited, so counting the HTTP/ lines counts the hops: the /old-books.csv route redirects with 302 Found and a Location header, and redirect = FALSE stops at the first answer. In the script above Date: is removed with h[!grepl("^Date:", h)] because it changes on every run, and trimws() drops the line endings for display.

The other arguments are verify = FALSE (skip certificate verification: the help page notes the risk is slight because only the headers are captured, and that looking at the URL in a browser usually reveals what is wrong), timeout (seconds, added in R 4.1.0; a value that is not positive means getOption("timeout") is used) and TLS (the minimum TLS version for https:// URLs: "1.1", "1.2" or "1.3", also R 4.1.0, if libcurl and its SSL library support it). Only 500 header lines are reported. curlGetHeaders() was added in R 3.2.0 together with capabilities("libcurl") and libcurlVersion(), and R uses it in R CMD check --as-cran to check that URLs exist (R 3.2.0 NEWS). R 4.0.3 fixed internet access based on libcurl, including curlGetHeaders(), not respecting the timeout option (R NEWS).

Encoding URLs

A URL may contain only a small set of characters; the others are written as % and two hexadecimal digits (percent-encoding; multi-byte characters are encoded byte by byte). utils::URLencode(URL, reserved = FALSE, repeated = FALSE) and URLdecode() do this. The reserved characters ! $ & ' ( ) * + , ; = : / ? @ # [ ] are left alone by default, "which is appropriate for file:// URLs, but probably not for http:// ones" (the help page). So URLencode() is right for a whole URL that contains a space, and wrong for the value of a query parameter, which may itself contain & or =: encode each value with reserved = TRUE and then build the query. With repeated = FALSE an apparently already-encoded %41 is left alone; TRUE encodes the percent sign again, which is rarely what you want.

# Percent-encoding with utils::URLencode() and URLdecode().
title <- "Salt & Static"
URLencode(title)
#> [1] "Salt%20&%20Static"
URLencode(title, reserved = TRUE)
#> [1] "Salt%20%26%20Static"
URLdecode("Salt%20%26%20Static")
#> [1] "Salt & Static"

# URLencode() leaves a whole URL's separators alone ...
URLencode("https://api.example.com/v1/books?title=Salt & Static")
#> [1] "https://api.example.com/v1/books?title=Salt%20&%20Static"

# ... so encode each value on its own and build the query by hand.
query <- paste0("title=", URLencode(title, reserved = TRUE), "&limit=5")
paste0("https://api.example.com/v1/books?", query)
#> [1] "https://api.example.com/v1/books?title=Salt%20%26%20Static&limit=5"

# repeated = TRUE encodes characters that are already encoded.
URLencode("100%", repeated = FALSE)
#> [1] "100%25"
URLencode("%41", repeated = FALSE)
#> [1] "%41"
URLencode("%41", repeated = TRUE)
#> [1] "%2541"

# The curl package escapes a value the same way.
curl::curl_escape(title)
#> [1] "Salt%20%26%20Static"
curl::curl_unescape("Salt%20%26%20Static")
#> [1] "Salt & Static"

# httr2 does the encoding for you when the query is a named list.
library(httr2)
request("https://api.example.com/v1/books") |>
  req_url_query(title = title, limit = 5) |>
  req_get_url()
#> [1] "https://api.example.com/v1/books?title=Salt%20%26%20Static&limit=5"

# url_parse() splits a URL into parts; url_modify() changes one of them.
u <- url_parse("https://api.example.com:8443/v1/books?genre=fiction&limit=2")
u$hostname
#> [1] "api.example.com"
u$port
#> [1] "8443"
u$path
#> [1] "/v1/books"
u$query
#> $genre
#> [1] "fiction"
#>
#> $limit
#> [1] "2"
#>
url_modify(
  "https://api.example.com/v1/books",
  query = list(genre = "poetry"),
  fragment = "top"
)
#> [1] "https://api.example.com/v1/books?genre=poetry#top"

The script ran without a network. title has a space and an ampersand: URLencode(title) leaves the ampersand, which a server would read as the start of the next parameter, while reserved = TRUE gives Salt%20%26%20Static. curl::curl_escape() does the same and is also what to use for a single path segment. httr2’s req_url_query() encodes each value for you, so with httr2 the problem does not arise for query parameters (the next sections show what it does not do for path segments). url_parse() splits a URL into the parts of an httr2_url (scheme, hostname, port, path, query as a list, fragment) and url_modify() changes parts of one.

https, certificates and proxies

None of this can run here, because it needs real remote servers, so this section is prose. The R FAQ entry 7.43 is the whole answer to "how do I enable https": from R 4.2.0 the "libcurl" download method is always available and used for HTTPS by default on all platforms, as it had been since R 3.3.0 everywhere but Windows (where the default "wininet" also supported HTTPS), so "nothing needs to be done to access https:// websites in recent versions of R" (R FAQ 7.43: How can I enable secure https downloads in R?). A tutorial that starts with options(download.file.method = "wininet") or method = "curl" to get https working describes R before 3.3.0 or before 4.2.0.

What can still go wrong is the same set of problems as in a browser:

  • Certificates. HTTPS methods try to verify the server’s certificate with the CA root certificates installed by the operating system, which can be missing or out of date, and the browser may use a different bundle, so a site that opens in a browser can still fail in R. On Windows with method = "libcurl" the certificates come from the OS when R was linked with Schannel (check that libcurlVersion() contains "Schannel"); otherwise set the environment variable CURL_CA_BUNDLE to the path of a certificate bundle file such as ca-bundle.crt. The help page points to curl documentation: SSL certificates for the details.

  • Revocation checks behind a proxy. On Windows with Schannel the connection fails if it cannot be established that the certificate has not been revoked, which some corporate man-in-the-middle proxies prevent. R 4.2.2 added the environment variable R_LIBCURL_SSL_REVOKE_BEST_EFFORT: set to TRUE it makes the revocation check best-effort, "with the consequence of reducing security" (?download.file and R NEWS: Changes in R 4.2.2). It is a workaround for one environment, not a setting to ship in a script.

  • Not verifying. curlGetHeaders(verify = FALSE) skips verification, and httr2 can pass libcurl options with req_options(); both defeat the purpose of HTTPS and are for diagnosing a problem, never for a fix that stays in a script.

  • Proxies. For the "libcurl" and "curl" methods proxies are set through environment variables. ?download.file names http_proxy and ftp_proxy; libcurl’s tutorial, which R’s help page links, explains that the variables are built as the lower-case protocol name followed by _proxy, so https_proxy applies to https:// URLs, all_proxy is the fallback and no_proxy lists hosts that bypass the proxy (libcurl tutorial). Set them in .Renviron or in the shell, not in code. httr2 has req_proxy(url, port, username, password, auth) for a proxy that applies to one request.

  • Timeouts and the user agent. options(timeout) and R_DEFAULT_INTERNET_TIMEOUT were described above. The HTTPUserAgent option sets the user agent string used by download.file(), url() and curlGetHeaders(); with libcurl the default is "libcurl/<version>" (a value starting with the "R " prefix is reserved and ignored). options(internet.info) sets how much is printed about URL downloads: the default 2 prints the failure causes, and 1 or 0 give more detail for the libcurl method (R documentation: options).

A fragment, not run (it needs a real server, and api.example.com is the placeholder host), shows the shape of a download that survives a slow network:

# Fragment, not run: a large file from a real server needs a longer timeout.
old <- options(timeout = max(600, getOption("timeout")))
status <- download.file(
  "https://api.example.com/files/books-2026.csv.gz",
  destfile = "books-2026.csv.gz",
  mode = "wb", quiet = TRUE
)
options(old)
if (status != 0L) stop("download failed", call. = FALSE)

browseURL()

utils::browseURL(url, browser = getOption("browser"), encodeIfNeeded = FALSE) opens a URL in a web browser. It is for interactive work (showing a documentation page or a generated HTML report), not a way to fetch data, and it does nothing useful in a script run on a server. On Unix-alikes the browser comes from the option "browser", which is set from the environment variable R_BROWSER; the value "false" suppresses showing URLs altogether. encodeIfNeeded = TRUE applies URLencode() first, which the help page warns can be harmful for file:// URLs on some systems and for URLs passed to some CGI applications (R documentation: browseURL).

httr2: requests and responses

Why httr2

httr2 is a package for talking to web APIs from R. It is a wrapper for the curl package, which binds libcurl, "customised to the demands of modern web APIs", and it is the successor of httr: the httr site calls its own package superseded, says that only changes needed to keep it on CRAN will be made, and recommends httr2 instead (httr package documentation). The design is the pipe: request() makes an immutable request object, req_*() functions return a modified copy, req_perform() sends it and returns a response object, and resp_*() functions read the response. Nothing is changed in place and there is no global configuration, apart from the session’s record of the last_request() and last_response() used for debugging. httr2 turns HTTP failures into R conditions with classes (httr2_http_404), supports retries, throttling, caching, paging and mocking, and has the OAuth flows. It uses jsonlite to write (req_body_json()) and to read (resp_body_json()) JSON. Its own examples run on R 4.2 and later, because they use the native pipe and the lambda syntax (httr2 1.0.0 NEWS).

Stage Functions

make

request(base_url)

shape the URL

req_url_path(), req_url_path_append(), req_url_query(), req_template()

shape the message

req_method(), req_headers(), req_headers_redacted(), req_user_agent(), req_body_json(), req_body_form(), req_body_multipart(), req_body_file(), req_body_raw(), req_cookies_set()

authenticate

req_auth_basic(), req_auth_bearer_token(), req_oauth_*()

behaviour

req_timeout(), req_retry(), req_throttle(), req_error(), req_cache(), req_proxy(), req_options(), req_progress(), req_verbose()

inspect without sending

print(), req_dry_run(), req_get_url(), req_get_method(), req_get_headers(), httr2_translate()

send

req_perform(), req_perform_sequential(), req_perform_parallel(), req_perform_iterative(), req_perform_connection(), req_perform_promise()

read

resp_status(), resp_headers(), resp_content_type(), resp_body_json(), resp_body_string(), resp_body_raw(), resp_body_html(), resp_body_xml(), resp_check_status()

Building a request

The first script builds requests and prints them or their URLs: it does not touch the network. request() takes the base URL; the host api.example.com is the placeholder of this page. The script calls Sys.setenv() only to give the example a dummy value that stands for what would normally come from .Renviron.

library(httr2)

# A request is an immutable object: every req_*() returns a modified copy.
# api.example.com is a placeholder host; nothing here is sent.
req <- request("https://api.example.com/v1")
req
#> <httr2_request>
#> GET https://api.example.com/v1
#> Body: empty
req_get_url(req)
#> [1] "https://api.example.com/v1"

books_req <- req |>
  req_url_path_append("books") |>
  req_url_query(genre = "fiction", limit = 2)
req_get_url(books_req)
#> [1] "https://api.example.com/v1/books?genre=fiction&limit=2"
req_get_url(req)
#> [1] "https://api.example.com/v1"

# req_url_path_append() adds segments; it does not escape them for you.
req |> req_url_path_append("books", "978-0-306-40615-7") |> req_get_url()
#> [1] "https://api.example.com/v1/books/978-0-306-40615-7"
segment <- "search results"
tryCatch(
  req |> req_url_path_append(segment) |> req_url_query(page = 1),
  error = \(e) conditionMessage(e)
)
#> [1] "Failed to parse URL: Malformed input to a URL function"
req |>
  req_url_path_append(curl::curl_escape(segment)) |>
  req_url_query(page = 1) |>
  req_get_url()
#> [1] "https://api.example.com/v1/search%20results?page=1"

# A vector value needs a strategy: .multi.
genres <- c("fiction", "poetry")
tryCatch(
  req |> req_url_query(genre = genres),
  error = \(e) conditionMessage(e)
)
#> [1] "All vector elements of `...` must be length 1.\nℹ Use `.multi` to choose a strategy for handling vectors."
req |> req_url_query(genre = genres, .multi = "explode") |> req_get_url()
#> [1] "https://api.example.com/v1?genre=fiction&genre=poetry"
req |> req_url_query(genre = genres, .multi = "comma") |> req_get_url()
#> [1] "https://api.example.com/v1?genre=fiction,poetry"
req |> req_url_query(genre = genres, .multi = "pipe") |> req_get_url()
#> [1] "https://api.example.com/v1?genre=fiction|poetry"

# Spaces are %20 by default; .space = "form" gives + for HTML-form style APIs.
req |> req_url_query(title = "Salt and Static") |> req_get_url()
#> [1] "https://api.example.com/v1?title=Salt%20and%20Static"
req |>
  req_url_query(title = "Salt and Static", .space = "form") |>
  req_get_url()
#> [1] "https://api.example.com/v1?title=Salt+and+Static"

# Headers: names are case-insensitive, a later value replaces an earlier one.
h <- books_req |>
  req_headers(Accept = "application/json", `X-Shelf-Client` = "docs") |>
  req_headers(accept = "text/csv")
req_get_headers(h)
#> $`X-Shelf-Client`
#> [1] "docs"
#>
#> $accept
#> [1] "text/csv"
#>

# .redact hides a header when the request is printed or logged. The value
# comes from the environment, never from the script ("demo-key" is a dummy).
Sys.setenv(SHELF_API_KEY = "demo-key")
h <- books_req |>
  req_headers(`X-Api-Key` = Sys.getenv("SHELF_API_KEY"), .redact = "X-Api-Key")
h
#> <httr2_request>
#> GET https://api.example.com/v1/books?genre=fiction&limit=2
#> Headers:
#> * X-Api-Key: <REDACTED>
#> Body: empty
names(req_get_headers(h))
#> character(0)
req_get_headers(h, redacted = "reveal")
#> $`X-Api-Key`
#> [1] "demo-key"
#>

# A user agent, a method and a timeout.
books_req |>
  req_user_agent("shelf-client/0.1 (docs example)") |>
  req_method("HEAD") |>
  req_timeout(5) |>
  print()
#> <httr2_request>
#> HEAD https://api.example.com/v1/books?genre=fiction&limit=2
#> Body: empty
#> Options:
#> * useragent     : "shelf-client/0.1 (docs example)"
#> * timeout_ms    : 5000
#> * connecttimeout: 0
req_get_method(books_req)
#> [1] "GET"
books_req |> req_method("DELETE") |> req_get_method()
#> [1] "DELETE"

What the script shows:

  • The request is a value. req_url_path_append("books") and req_url_query(...) return new requests; req is unchanged. req_get_url(), req_get_method() and req_get_headers() (httr2 1.2.0) read a request back.

  • req_url_path_append() does not escape its argument. A segment with a space is appended as it is, and the next req_url_query() fails to parse the URL with Failed to parse URL: Malformed input to a URL function (the message seen with the checked versions of httr2 and curl). Escape a segment that came from data with curl::curl_escape() first. Query values, on the other hand, are escaped by req_url_query().

  • A vector needs a strategy. req_url_query(genre = c("fiction", "poetry")) is an error ("All vector elements of ... must be length 1"). The argument .multi (httr2 1.0.0) chooses: "explode" repeats the parameter (genre=fiction&genre=poetry), "comma" joins with commas, "pipe" with |. Which one an API expects is in its documentation. .space = "form" (httr2 1.1.0) writes spaces as + instead of %20.

  • Headers. Names are case-insensitive and a later req_headers() replaces an earlier one (so the second call replaced Accept, keeping the new spelling accept). .redact names headers whose values must not be shown when the request is printed. req_get_headers() leaves the redacted ones out unless you ask redacted = "reveal". The printed request marks them <REDACTED>. req_headers_redacted() (httr2 1.1.0) is the shortcut for a header that is all secret.

  • User agent, method and timeout. req_user_agent() sets the User-Agent; without it httr2 sends httr2/<version> r-curl/<version> libcurl/<version>. It is polite, and sometimes required, to identify the client with a name and a contact. req_method("HEAD") or "DELETE" changes the method; a body makes it POST by default. req_timeout(seconds) sets the total time; the printed request shows the libcurl options (timeout_ms, connecttimeout) it set. Since httr2 1.0.1 req_timeout() also resets the connection timeout, so it can be used to raise it above libcurl’s default of 10 seconds.

  • req_template("GET /books/{isbn}", isbn = ...) builds the method and path from a template, and req_options() passes raw libcurl options (req_options(ssl_verifypeer = FALSE) is how verification is switched off, with the risks described above).

Dry runs, bodies and authentication

req_dry_run() prints the HTTP request httr2 would send, without sending it: the request line, the headers and the body, with a JSON body pretty-printed. It does this with curl::curl_echo(), a small server on localhost that echoes the request, so it needs the httpuv package but no network (the requests below go to the placeholder host and never leave R). Two observations from the output: the first line shows the method and the path, not the query string (use req_get_url() to check the query); and since httr2 1.1.1 a dry run in tests omits the headers that would vary. The accept, accept-encoding and host lines are the ones libcurl adds. The default user-agent is the one httr2 builds from its own and curl’s versions, so the script sets one explicitly to keep the output stable.

library(httr2)

# req_dry_run() prints the HTTP request httr2 would send, without sending it.
# The host is a placeholder and no network access is used.
req <- request("https://api.example.com/v1") |>
  req_user_agent("shelf-client/0.1") |>
  req_url_path_append("books", curl::curl_escape("search results")) |>
  req_url_query(genre = "fiction", limit = 2)
req_dry_run(req)
#> GET /v1/books/search%20results HTTP/1.1
#> accept: */*
#> accept-encoding: deflate, gzip, br, zstd
#> host: api.example.com
#> user-agent: shelf-client/0.1
#>

# A JSON body: req_body_json() sets Content-Type and POSTs by default.
new_book <- list(title = "Ten Winters", isbn = "9780013001331", year = 2022L)
req |>
  req_url_path("/v1/books") |>
  req_body_json(new_book) |>
  req_dry_run()
#> POST /v1/books HTTP/1.1
#> accept: */*
#> accept-encoding: deflate, gzip, br, zstd
#> content-length: 58
#> content-type: application/json
#> host: api.example.com
#> user-agent: shelf-client/0.1
#>
#> {
#>   "title": "Ten Winters",
#>   "isbn": "9780013001331",
#>   "year": 2022
#> }

# A form body, with an explicit method.
req |>
  req_url_path("/v1/books/9780013001331") |>
  req_body_form(year = 2022, genre = "Fiction") |>
  req_method("PATCH") |>
  req_dry_run()
#> PATCH /v1/books/9780013001331 HTTP/1.1
#> accept: */*
#> accept-encoding: deflate, gzip, br, zstd
#> content-length: 23
#> content-type: application/x-www-form-urlencoded
#> host: api.example.com
#> user-agent: shelf-client/0.1
#>

# A bearer token from the environment. The Authorization header is redacted
# when the request is printed and in the dry run. "demo-token" is a dummy.
Sys.setenv(SHELF_API_TOKEN = "demo-token")
authed <- req |>
  req_url_path("/v1/me") |>
  req_auth_bearer_token(Sys.getenv("SHELF_API_TOKEN"))
authed
#> <httr2_request>
#> GET https://api.example.com/v1/me?genre=fiction&limit=2
#> Headers:
#> * Authorization: <REDACTED>
#> Body: empty
#> Options:
#> * useragent: "shelf-client/0.1"
req_dry_run(authed)
#> GET /v1/me HTTP/1.1
#> accept: */*
#> accept-encoding: deflate, gzip, br, zstd
#> authorization: <REDACTED>
#> host: api.example.com
#> user-agent: shelf-client/0.1
#>

# Basic authentication works the same way.
Sys.setenv(SHELF_API_PASSWORD = "demo-password")
req |>
  req_url_path("/v1/me") |>
  req_auth_basic("reader", Sys.getenv("SHELF_API_PASSWORD")) |>
  req_dry_run()
#> GET /v1/me HTTP/1.1
#> accept: */*
#> accept-encoding: deflate, gzip, br, zstd
#> authorization: <REDACTED>
#> host: api.example.com
#> user-agent: shelf-client/0.1
#>

# The redaction is a display feature: the value is still in the request.
names(req_get_headers(authed))
#> character(0)
req_get_headers(authed, redacted = "reveal")[["Authorization"]]
#> [1] "Bearer demo-token"

Notes on this script:

  • Bodies. req_body_json(data) writes the list with jsonlite (scalars unboxed by default: auto_unbox = TRUE), sets Content-Type: application/json and makes the method POST; req_body_form(...) sends application/x-www-form-urlencoded; req_body_multipart() sends multipart/form-data for file uploads, req_body_file() and req_body_raw() send a file or bytes as they are. req_method() after the body overrides the method (the form example is a PATCH). Changing the body type of a request that already has one is an error (httr2 1.0.4).

  • Bearer tokens. req_auth_bearer_token(Sys.getenv("SHELF_API_TOKEN")) adds Authorization: Bearer <token>. The token comes from an environment variable, never from the script; the script sets a dummy value only so that the example is complete. req_auth_basic(username, password) adds Authorization: Basic <base64> and should be used only with https://.

  • The Authorization header is redacted when the request is printed and in the dry run, as the output shows: Authorization: <REDACTED> and authorization: <REDACTED>. httr2 redacts Authorization automatically in req_headers() and the req_auth_*() functions (httr2 1.1.1) and str() of a request redacts it too. This is a display feature: req_get_headers(authed, redacted = "reveal") returns the value. Redacted headers are not saved when a request is serialised to disk (httr2 1.2.0), which means a request saved with saveRDS() cannot be performed after reloading; that is deliberate, and it keeps secrets out of files.

  • Other credentials: req_auth_aws_v4() signs requests for AWS; obfuscate() and secret_encrypt_file() keep a secret out of source code; the req_oauth_*() family is described below.

Performing a request and reading the response

req_perform(req, path = NULL, verbosity = NULL, mock = ...) sends the request and returns an httr2_response if the server answered with a successful status; a 4xx or 5xx status is turned into an R error (next section), and so is a failure to get any answer. With path, the body is streamed to a file instead of held in memory (the body is then an httr2_path). The script starts the local server with a data route, a CSV route and a POST route.

library(httr2)
source("scenario.R")

# A local test server: the shelf books as JSON and CSV, and a POST route.
shelf_api <- function(books, csv, port) {
  plumber::pr() |>
    plumber::pr_set_serializer(plumber::serializer_unboxed_json()) |>
    plumber::pr_get("/books", \(res, limit = "100") {
      res$setHeader("X-Total-Count", nrow(books))
      books[seq_len(min(as.integer(limit), nrow(books))), ]
    }) |>
    plumber::pr_get(
      "/books.csv", \() csv,
      serializer = plumber::serializer_text(type = "text/csv")
    ) |>
    plumber::pr_post("/books", \(req, res) {
      res$status <- 201L
      res$setHeader("Location", "/books/new")
      list(created = req$body$title, year = req$body$year)
    }) |>
    plumber::pr_run(host = "127.0.0.1", port = port, docs = FALSE, quiet = TRUE)
}

csv <- readChar("books.csv", file.size("books.csv"))
srv <- callr::r_bg(
  shelf_api,
  args = list(books = books, csv = csv, port = 8765L)
)
base <- "http://127.0.0.1:8765"
request(base) |>
  req_retry(max_tries = 20, retry_on_failure = TRUE, backoff = \(i) 0.25) |>
  req_error(is_error = \(resp) FALSE) |>
  req_perform() |>
  invisible()

resp <- request(base) |>
  req_url_path_append("books") |>
  req_url_query(limit = 3) |>
  req_perform()
resp
#> <httr2_response>
#> GET http://127.0.0.1:8765/books?limit=3
#> Status: 200 OK
#> Content-Type: application/json
#> Body: In memory (380 bytes)

# Status, content type and headers.
resp_status(resp)
#> [1] 200
resp_status_desc(resp)
#> [1] "OK"
resp_content_type(resp)
#> [1] "application/json"
resp_header(resp, "x-total-count")
#> [1] "16"
resp_header(resp, "X-No-Such-Header", default = "none")
#> [1] "none"
resp_headers(resp, filter = "^X-Total|^Content-Type")
#> <httr2_headers>
#> X-Total-Count: 16
#> Content-Type: application/json

# The body, three ways.
resp_has_body(resp)
#> [1] TRUE
raw_body <- resp_body_raw(resp)
class(raw_body)
#> [1] "raw"
length(raw_body)
#> [1] 380
substr(resp_body_string(resp), 1L, 60L)
#> [1] "[{\"title\":\"The Dispossessed\",\"author\":\"Ursula K. Le Guin\",\"i"
resp_body_json(resp) |> length()
#> [1] 3
resp_body_json(resp)[[1L]][c("title", "year")]
#> $title
#> [1] "The Dispossessed"
#>
#> $year
#> [1] 1974
#>
resp_body_json(resp, simplifyVector = TRUE) |> str()
#> 'data.frame':	3 obs. of  6 variables:
#>  $ title : chr  "The Dispossessed" "Pride and Prejudice" "  the quiet streets "
#>  $ author: chr  "Ursula K. Le Guin" "Jane Austen" "Mara Quill"
#>  $ isbn  : chr  "978-0-306-40615-7" "978-0-14-143951-8" "9780001000100"
#>  $ year  : int  1974 1813 2019
#>  $ genre : chr  "Science fiction" "Classic" "Fiction"
#>  $ pages : int  387 432 214

# Which request produced this response?
resp_url(resp)
#> [1] "http://127.0.0.1:8765/books?limit=3"
resp_request(resp) |> req_get_url()
#> [1] "http://127.0.0.1:8765/books?limit=3"
last_response()$status_code
#> [1] 200

# resp_body_json() insists on a JSON content type.
csv_resp <- request(base) |> req_url_path_append("books.csv") |> req_perform()
resp_content_type(csv_resp)
#> [1] "text/csv"
tryCatch(resp_body_json(csv_resp), error = \(e) conditionMessage(e))
#> [1] "Unexpected content type \"text/csv\".\n• Expecting type \"application/json\" or suffix \"json\"."
read.csv(text = resp_body_string(csv_resp)) |> dim()
#> [1] 16  6

# Save a body straight to a file with path = ; the body is then a path.
dest <- tempfile(fileext = ".csv")
saved <- request(base) |>
  req_url_path_append("books.csv") |>
  req_perform(path = dest)
class(saved$body)
#> [1] "httr2_path"
file.size(dest)
#> [1] 1187

# POST a JSON body: 201 Created and the Location header.
created <- request(base) |>
  req_url_path_append("books") |>
  req_body_json(list(title = "Ten Winters", year = 2022L)) |>
  req_perform()
resp_status(created)
#> [1] 201
resp_header(created, "Location")
#> [1] "/books/new"
resp_body_json(created)
#> $created
#> [1] "Ten Winters"
#>
#> $year
#> [1] 2022
#>
req_get_method(resp_request(created))
#> [1] "POST"

invisible(srv$kill())

Reading the output:

  • A printed response shows the method and URL, the status line, the content type and the size of the body in memory. resp_status() is the number, resp_status_desc() the reason phrase, and resp_content_type() the media type without parameters such as charset.

  • resp_header(resp, name) returns one header as a string or NULL; it ignores the case of the name, and default = supplies a value for a missing header. resp_headers(resp, filter = regex) returns an httr2_headers list, filtered by a regular expression. The Date header and the transfer headers are left out of the example because they change from run to run or depend on the server.

  • The body is read with resp_body_raw() (the bytes), resp_body_string() (text, using the declared encoding; resp_encoding() shows it), resp_body_json() (parsed with jsonlite: lists by default, simplifyVector = TRUE gives vectors and data frames, extra arguments go to jsonlite::fromJSON()), and resp_body_html() and resp_body_xml() (the xml2 package). resp_has_body() tells whether there is one. Since httr2 1.0.0 resp_body_json() and resp_body_xml() cache what they parsed, so calling them repeatedly is cheap.

  • The content type is checked. resp_body_json() on a text/csv response fails with Unexpected content type "text/csv" and Expecting type "application/json" or suffix "json", so a response that is not JSON is never parsed as if it were. resp_check_content_type() (httr2 1.0.0) is the check on its own. resp_body_string() is the way to read the CSV body, here with read.csv(text = ...).

  • resp_request(resp) returns the request that produced a response (httr2 1.1.0), resp_url(resp) the final URL after redirects, and resp_timing(resp) the timing libcurl measured (httr2 1.2.0). last_request() and last_response() return the most recent request and response, including a failed one, and last_response_json() shows a JSON body (httr2 1.2.0; with content types ending in +json from httr2 1.2.3).

  • A POST with a JSON body returned 201 and a Location header; the plumber route parsed the body into req$body. req_perform(req, path = dest) saved the CSV to a file; the printed response of such a request contains the temporary path, so the script prints the class of the body and the size of the file instead.

  • req_verbose() and with_verbosity() print the traffic (header_req, header_resp, body_req, body_resp, info), with Authorization redacted; the environment variable HTTR2_VERBOSITY sets a default (httr2 1.1.1). Progress bars appear when R waits or downloads; options(httr2_progress = FALSE) suppresses them.

HTTP errors and failed requests

httr2 distinguishes two ways for a request to fail, as ?req_error explains. The request may not complete at all (the connection drops, the server does not exist, the time runs out): the condition has the classes httr2_failure and httr2_error, and its parent is the curl error. Or the request may succeed but the status code is an error, 4xx or 5xx: the classes are, for a 404, httr2_http_404, httr2_http and httr2_error. Since httr2 1.0.0 every error httr2 throws inherits from httr2_error. These classes are meant for tryCatch() (the Conditions and Error Handling page explains class-based handling), so a script can answer "no such book" differently from "the server is down".

library(httr2)
source("scenario.R")

# A local test server: a missing book (404 with a JSON body), an endpoint
# that always fails (500), a protected route (401) and a slow one.
shelf_api <- function(books, port) {
  plain <- gsub("-", "", books$isbn)
  plumber::pr() |>
    plumber::pr_set_serializer(plumber::serializer_unboxed_json()) |>
    plumber::pr_get("/books/<isbn>", \(res, isbn) {
      hit <- books[plain == gsub("-", "", isbn), c("title", "isbn")]
      if (nrow(hit) == 0L) {
        res$status <- 404L
        return(list(error = "no such book", isbn = isbn))
      }
      as.list(hit)
    }) |>
    plumber::pr_get("/boom", \(res) {
      res$status <- 500L
      list(error = "the shelf is on fire", code = "E500")
    }) |>
    plumber::pr_get("/secure", \(req, res) {
      if (!identical(req$HTTP_AUTHORIZATION, "Bearer demo-token")) {
        res$status <- 401L
        return(list(error = "token missing or wrong"))
      }
      list(user = "shelf-reader")
    }) |>
    plumber::pr_get("/slow", \() {
      Sys.sleep(1)
      list(ok = TRUE)
    }) |>
    plumber::pr_run(host = "127.0.0.1", port = port, docs = FALSE, quiet = TRUE)
}

srv <- callr::r_bg(shelf_api, args = list(books = books, port = 8765L))
base <- "http://127.0.0.1:8765"
request(base) |>
  req_retry(max_tries = 20, retry_on_failure = TRUE, backoff = \(i) 0.25) |>
  req_error(is_error = \(resp) FALSE) |>
  req_perform() |>
  invisible()

missing_book <- request(base) |> req_url_path_append("books", "000")

# 1. By default a 4xx/5xx status is an R error with a class per status.
err <- tryCatch(req_perform(missing_book), error = identity)
conditionMessage(err)
#> [1] "HTTP 404 Not Found."
class(err)
#> [1] "httr2_http_404" "httr2_http"     "httr2_error"    "rlang_error"
#> [5] "rlang_error"    "error"          "condition"

# 2. Handle one status, or any HTTP error, by class.
tryCatch(req_perform(missing_book), httr2_http_404 = \(e) "not on the shelf")
#> [1] "not on the shelf"
tryCatch(
  request(base) |> req_url_path_append("boom") |> req_perform(),
  httr2_http_404 = \(e) "not on the shelf",
  httr2_http = \(e) paste("HTTP error", e$status)
)
#> [1] "HTTP error 500"

# 3. The response travels with the error.
resp_status(err$resp)
#> [1] 404
resp_body_json(err$resp)
#> $error
#> [1] "no such book"
#>
#> $isbn
#> [1] "000"
#>

# 4. req_error(body = ) puts the API's own message into the R error.
api_message <- \(resp) paste("The API says:", resp_body_json(resp)$error)
explained <- request(base) |>
  req_url_path_append("boom") |>
  req_error(body = api_message)
err <- tryCatch(req_perform(explained), error = identity)
class(err)[1:2]
#> [1] "httr2_http_500" "httr2_http"
cat(conditionMessage(err), "\n")
#> HTTP 500 Internal Server Error.
#> ℹ The API says: the shelf is on fire

# 5. req_error(is_error = ) decides what counts as an error.
lenient <- missing_book |> req_error(is_error = \(resp) FALSE)
resp <- req_perform(lenient)
resp_status(resp)
#> [1] 404
resp_is_error(resp)
#> [1] TRUE
resp_status_desc(resp)
#> [1] "Not Found"
err <- tryCatch(resp_check_status(resp), error = identity)
conditionMessage(err)
#> [1] "HTTP 404 Not Found."
class(err)[1L]
#> [1] "httr2_http_404"

# 6. The last request and response are kept for debugging.
last_response()$status_code
#> [1] 404
req_get_url(last_request())
#> [1] "http://127.0.0.1:8765/books/000"

# 7. 401: a bearer token from the environment ("demo-token" is a dummy).
secure <- request(base) |> req_url_path_append("secure")
tryCatch(req_perform(secure), httr2_http_401 = \(e) conditionMessage(e))
#> [1] "HTTP 401 Unauthorized."
Sys.setenv(SHELF_API_TOKEN = "demo-token")
secure |>
  req_auth_bearer_token(Sys.getenv("SHELF_API_TOKEN")) |>
  req_perform() |>
  resp_body_json()
#> $user
#> [1] "shelf-reader"
#>

# 8. A timeout is a failure of the transfer, not an HTTP status.
slow <- request(base) |> req_url_path_append("slow") |> req_timeout(0.3)
err <- tryCatch(req_perform(slow), error = identity)
class(err)[1:2]
#> [1] "httr2_failure" "httr2_error"
sub("after [0-9]+ milliseconds", "after N milliseconds", conditionMessage(err))
#> [1] "Failed to perform HTTP request.\nCaused by error in `curl::curl_fetch_memory()`:\n! Timeout was reached [127.0.0.1]:\nOperation timed out after N milliseconds with 0 bytes received"
class(err$parent)[1L]
#> [1] "curl_error_operation_timedout"

# 9. A server that is gone is a failure too.
invisible(srv$kill())
err <- tryCatch(req_perform(missing_book), error = identity)
class(err)[1:2]
#> [1] "httr2_failure" "httr2_error"
sub("after [0-9]+ ms", "after N ms", conditionMessage(err))
#> [1] "Failed to perform HTTP request.\nCaused by error in `curl::curl_fetch_memory()`:\n! Couldn't connect to server [127.0.0.1]:\nFailed to connect to 127.0.0.1 port 8765 after N ms: Couldn't connect to server"
class(err$parent)[1L]
#> [1] "curl_error_couldnt_connect"

Reading the output:

  1. The default message is HTTP 404 Not Found. with no word of what the API said; the classes are httr2_http_404, httr2_http, httr2_error, then the generic rlang_error, error and condition.

  2. tryCatch() with a handler named after a class handles that class: httr2_http_404 only for a 404, httr2_http for any HTTP error status (the 500 is handled by the second). Order matters when a more specific class comes first.

  3. The response travels with the error as err$resp, so the body the API sent is still readable (resp_body_json(err$resp)); last_response() gives the same.

  4. req_error(body = f) calls f(resp) on an error response and appends the character vector it returns as extra bullets to the message. The API said the shelf is on fire, and the R error now says it too: HTTP 500 Internal Server Error. followed by ℹ The API says: the shelf is on fire. This is the main reason to use req_error(), and the pattern of the httr2 article on wrapping APIs (httr2 package documentation: Wrapping APIs).

  5. req_error(is_error = f) decides what counts as an error: \(resp) FALSE turns the 404 into an ordinary response, which can then be tested with resp_is_error() and resp_status_desc(). resp_check_status() raises the error afterwards if you want it (HTTP 404 Not Found., class httr2_http_404).

  6. The last request and response remain available.

  7. A 401 is an HTTP error like the others. With the dummy bearer token from the environment the same request succeeds.

  8. A timeout is a failure, not a status. req_timeout(0.3) against the one-second route gives class httr2_failure; the message is Failed to perform HTTP request., then Caused by error in curl::curl_fetch_memory():, then libcurl’s Timeout was reached [127.0.0.1]: Operation timed out after N milliseconds with 0 bytes received. The script masks the number of milliseconds, which varies. The parent has class curl_error_operation_timedout.

  9. A server that is gone is a failure too, with the parent class curl_error_couldnt_connect and the message Couldn't connect to server. The script catches it with tryCatch(error = identity); httr2_failure can be handled by name.

The same classes can be used with withCallingHandlers() to add context and re-throw: withCallingHandlers(req |> req_perform(), httr2_http_404 = \(cnd) rlang::abort("Couldn't find the book", parent = cnd)) is the example in ?req_error.

Retries and rate limits

Many APIs limit how fast a client may call them and answer 429 Too Many Requests with a Retry-After header, or fail now and then with a 503. httr2 has two tools, which work together. req_retry() reacts to what the server says; req_throttle() keeps the client under a limit that the server does not report. A third idea, in httr2 1.1.0, is a circuit breaker: failure_threshold makes requests to a host that keeps failing error at once for failure_timeout seconds, without waiting for the server each time.

req_retry(req, max_tries, max_seconds, retry_on_failure = FALSE, is_transient, backoff, after, failure_threshold, failure_timeout, failure_realm) retries when the response is a 429 or a 503, or whatever is_transient(resp) says. max_tries is the total number of attempts, so it should be greater than one; when neither max_tries nor max_seconds is set, httr2 uses max_tries = 2 (since httr2 1.1.0). The delay before the next attempt is the number of seconds in the Retry-After header when there is one (after overrides how that is read: a function from the response to a number of seconds, or NA for "fall back to the backoff"); otherwise it is the backoff function, whose default is truncated exponential backoff with full jitter, a random wait between one second and 2^tries seconds, capped at 60. retry_on_failure = TRUE also retries when the request itself failed, which is how the examples wait for the local server to start; since httr2 1.0.4 such failures are no longer retried by default.

library(httr2)

# A local test server with a rate limit: /busy answers 429 with a Retry-After
# header twice and then succeeds; /limited never succeeds; /flaky fails once
# with a 500; /ok always succeeds.
shelf_api <- function(port) {
  seen <- new.env()
  seen$busy <- 0L
  seen$flaky <- 0L
  plumber::pr() |>
    plumber::pr_set_serializer(plumber::serializer_unboxed_json()) |>
    plumber::pr_get("/ok", \() list(ok = TRUE)) |>
    plumber::pr_get("/busy", \(res) {
      seen$busy <- seen$busy + 1L
      if (seen$busy <= 2L) {
        res$status <- 429L
        res$setHeader("Retry-After", "1")
        return(list(error = "slow down"))
      }
      list(ok = TRUE, attempt = seen$busy)
    }) |>
    plumber::pr_get("/flaky", \(res) {
      seen$flaky <- seen$flaky + 1L
      if (seen$flaky <= 1L) {
        res$status <- 500L
        return(list(error = "hiccup"))
      }
      list(ok = TRUE, attempt = seen$flaky)
    }) |>
    plumber::pr_get("/limited", \(res) {
      res$status <- 429L
      res$setHeader("Retry-After", "1")
      list(error = "slow down")
    }) |>
    plumber::pr_run(host = "127.0.0.1", port = port, docs = FALSE, quiet = TRUE)
}

srv <- callr::r_bg(shelf_api, args = list(port = 8765L))
base <- "http://127.0.0.1:8765"
request(base) |>
  req_url_path_append("ok") |>
  req_retry(max_tries = 20, retry_on_failure = TRUE, backoff = \(i) 0.25) |>
  req_perform() |>
  invisible()

# Without req_retry() a 429 is an ordinary HTTP error.
busy <- request(base) |> req_url_path_append("busy")
limited <- request(base) |> req_url_path_append("limited")
tryCatch(req_perform(limited), httr2_http_429 = \(e) conditionMessage(e))
#> [1] "HTTP 429 Too Many Requests."

# req_retry() retries 429 and 503. httr2 waits for the number of seconds in
# Retry-After; the server count shows the third attempt succeeded.
started <- proc.time()[["elapsed"]]
resp <- busy |> req_retry(max_tries = 4) |> req_perform()
resp_body_json(resp)
#> $ok
#> [1] TRUE
#>
#> $attempt
#> [1] 3
#>
proc.time()[["elapsed"]] - started >= 2
#> [1] TRUE

# The same wait spelled out: after = reads the header; backoff = is used when
# after returns NA.
retry_after <- \(resp) as.numeric(resp_header(resp, "Retry-After", "NA"))
policy <- limited |>
  req_retry(max_tries = 2, after = retry_after, backoff = \(i) 2^i)
err <- tryCatch(req_perform(policy), error = identity)
class(err)[1:2]
#> [1] "httr2_http_429" "httr2_http"
conditionMessage(err)
#> [1] "HTTP 429 Too Many Requests."
resp_retry_after(err$resp)
#> [1] 1

# is_transient decides which responses are retried: also treat 500 as transient.
flaky <- request(base) |> req_url_path_append("flaky")
tryCatch(req_perform(flaky), httr2_http_500 = \(e) conditionMessage(e))
#> [1] "HTTP 500 Internal Server Error."
transient <- \(resp) resp_status(resp) %in% c(429L, 500L, 503L)
flaky |>
  req_retry(max_tries = 3, is_transient = transient, backoff = \(i) 0.1) |>
  req_perform() |>
  resp_body_json()
#> $ok
#> [1] TRUE
#>
#> $attempt
#> [1] 2
#>

# Rate limit on the client: a token bucket. capacity = 2 per second means two
# requests are free, then one request every half second.
ok <- request(base) |> req_url_path_append("ok")
throttled <- ok |> req_throttle(capacity = 2, fill_time_s = 1)
started <- proc.time()[["elapsed"]]
for (i in seq_len(5L)) req_perform(throttled)
proc.time()[["elapsed"]] - started > 1
#> [1] TRUE

# rate = is the older spelling: rate times fill_time_s (60 s by default) is
# the capacity, so rate = 2 / 1 is a bucket of 120 and nothing waits here.
old_style <- ok |> req_throttle(rate = 2 / 1)
started <- proc.time()[["elapsed"]]
for (i in seq_len(5L)) req_perform(old_style)
proc.time()[["elapsed"]] - started < 1
#> [1] TRUE

invisible(srv$kill())

Reading the output:

  • Without req_retry() the 429 is an ordinary HTTP error, class httr2_http_429.

  • With req_retry(max_tries = 4) the same request succeeded on the third attempt: the server counted three requests (attempt is 3) and answered 429 with Retry-After: 1 for the first two. httr2 waited the two seconds that the header asked for, which the script checks (the elapsed time is at least 2 seconds). A req_retry() that gives up raises the error of the last response: with max_tries = 2 against the route that never succeeds, the error is HTTP 429 Too Many Requests., class httr2_http_429, and resp_retry_after(err$resp) reads the header, 1.

  • after = retry_after is the same wait written out: it reads Retry-After as a number and returns NA when the header is absent, which makes httr2 use backoff. Many APIs report the limit in other headers (GitHub uses X-RateLimit-Reset); after is how to read them, and is_transient is how to say that a 403 with X-RateLimit-Remaining: 0 is a rate limit (the examples in ?req_retry).

  • is_transient replaced the default test: the /flaky route failed once with a 500; without it the call errors with HTTP 500 Internal Server Error., with it the second attempt succeeds. Only retry what is safe to repeat: a GET is, a POST that creates something may not be.

  • Throttling. req_throttle() is a token bucket (httr2 1.1.1): the bucket holds at most capacity tokens and refills over fill_time_s seconds, every request takes a token, and an empty bucket makes the request wait. capacity = 2, fill_time_s = 1 means two requests at once and then one every half second, so five requests took more than one second. The older argument rate is still accepted: rate times fill_time_s (60 s by default) becomes the capacity, so rate = 2 / 1 is a bucket of 120 tokens per minute and nothing waited. A vector of capacities and times enforces several limits at once, such as 10 requests per second and 100 per minute (httr2 1.2.3). The throttle applies to every request with the same realm, by default the host name; since httr2 1.2.2 req_throttle() no longer resets the bucket when it is used repeatedly with the same host and never produces negative waits.

  • req_perform_parallel() supports simplified versions of req_throttle() and req_retry() (httr2 1.1.1). Mocked responses are not retried (see the mocking section).

Pagination and several requests

An API that returns many records pages them: a limit and an offset, a cursor, or Link headers. req_perform_iterative(req, next_req, max_reqs = 20, on_error) (httr2 1.0.0) performs a request, passes the response to next_req(resp, req), which returns the next request or NULL to stop, and so on, returning the list of responses. The helpers iterate_with_offset(param_name, start, offset, resp_pages, resp_complete), iterate_with_cursor() and iterate_with_link_url() build next_req for the usual designs. resps_data(resps, resp_data) combines the parsed bodies, and resps_successes(), resps_failures(), resps_requests() and resps_ok() (httr2 1.2.2) sort the list. When the number of pages is known, req_perform_parallel(reqs, max_active = 10, on_error = c("stop", "return", "continue")) sends the requests at once, and req_perform_sequential() one by one with the same interface. The local server reports the total in the X-Total-Count header.

library(httr2)
source("scenario.R")

# A local test server: /books returns one page, "limit" rows from "offset",
# and reports the total in the X-Total-Count header.
shelf_api <- function(books, port) {
  plumber::pr() |>
    plumber::pr_set_serializer(plumber::serializer_unboxed_json()) |>
    plumber::pr_get("/books", \(res, limit = "5", offset = "0") {
      rows <- as.integer(offset) + seq_len(as.integer(limit))
      res$setHeader("X-Total-Count", nrow(books))
      books[rows[rows <= nrow(books)], c("title", "isbn", "year")]
    }) |>
    plumber::pr_run(host = "127.0.0.1", port = port, docs = FALSE, quiet = TRUE)
}

srv <- callr::r_bg(shelf_api, args = list(books = books, port = 8765L))
base <- "http://127.0.0.1:8765"
req <- request(base) |>
  req_url_path_append("books") |>
  req_url_query(limit = 5) |>
  req_retry(max_tries = 20, retry_on_failure = TRUE, backoff = \(i) 0.25)
invisible(req_perform(req))

# How many pages? The total is in a header.
total <- \(resp) as.integer(resp_header(resp, "X-Total-Count"))
pages <- \(resp) ceiling(total(resp) / 5)

# req_perform_iterative(): each request is made from the previous response.
resps <- req_perform_iterative(
  req,
  next_req = iterate_with_offset("offset", start = 0, offset = 5,
                                 resp_pages = pages),
  progress = FALSE
)
length(resps)
#> [1] 4
vapply(resps, resp_status, integer(1L))
#> [1] 200 200 200 200
req_get_url(resp_request(resps[[2L]]))
#> [1] "http://127.0.0.1:8765/books?limit=5&offset=5"

# resps_data() combines the parsed bodies; here, a data frame per page.
page_data <- \(resp) resp_body_json(resp, simplifyVector = TRUE)
shelf <- resps_data(resps, page_data)
dim(shelf)
#> [1] 16  3
shelf$title[c(1L, 16L)]
#> [1] "The Dispossessed" "Salt and Static"

# max_reqs caps the loop (the default is 20).
two <- req_perform_iterative(
  req,
  next_req = iterate_with_offset("offset", start = 0, offset = 5,
                                 resp_pages = pages),
  max_reqs = 2,
  progress = FALSE
)
length(two)
#> [1] 2

# When the page count is known, build the requests and send them together.
offsets <- seq(0L, 15L, by = 5L)
reqs <- lapply(offsets, \(o) req |> req_url_query(offset = o))
par_resps <- req_perform_parallel(reqs, progress = FALSE)
vapply(par_resps, resp_status, integer(1L))
#> [1] 200 200 200 200
identical(resps_data(par_resps, page_data), shelf)
#> [1] TRUE

# req_perform_sequential() has the same interface, one request at a time.
seq_resps <- req_perform_sequential(reqs, progress = FALSE)
identical(resps_data(seq_resps, page_data), shelf)
#> [1] TRUE

# on_error = "continue" keeps going and returns the failures with the rest.
bad <- c(reqs[1:2], list(request(base) |> req_url_path_append("nope")))
mixed <- req_perform_parallel(bad, on_error = "continue", progress = FALSE)
vapply(mixed, \(x) class(x)[1L], character(1L))
#> [1] "httr2_response" "httr2_response" "httr2_http_404"
length(resps_successes(mixed))
#> [1] 2
length(resps_failures(mixed))
#> [1] 1
resps_ok(mixed)
#> [1]  TRUE  TRUE FALSE

invisible(srv$kill())

The sixteen books come in four pages of five, and resp_pages computed the number of pages from the total in the header. resps_data() rebuilt the full data frame, identical for the iterative, parallel and sequential versions. max_reqs caps the loop. With on_error = "continue" the failing request becomes an error object in the list (here class httr2_http_404) and the others still complete; resps_ok() is a logical vector of the successes. Since httr2 1.1.1, req_perform_parallel() lifts many restrictions (it checks the cache before and after each request and can refresh OAuth tokens) and its pool argument was replaced by max_active; the argument was removed in httr2 1.2.0. req_perform_iterative() is no longer experimental since httr2 1.0.4.

Caching

req_cache(req, path, use_on_error = FALSE, debug, max_age, max_n, max_size) stores the responses to GET requests that have status 200 and at least one standard caching header (Expires, ETag, Last-Modified, Cache-Control), unless Cache-Control: no-store forbids it. Most API requests are not cacheable; static files often are. A request is still sent to check that the cached value is up to date, unless the cached value is still fresh by max-age, in which case the server is not contacted at all. path is a directory that is created automatically (tempfile() for a session cache, tools::R_user_dir() for a persistent one); the cache prunes itself to max_age, max_n and max_size (the default size limit is 1 GB). debug = TRUE (or options(httr2_cache_debug = TRUE)) prints messages about cache hits and misses; they include a hash that is not printed in the example. httr2 1.3.0 requires rlang 1.3.0, which changes the hash that names the cache files, so files cached by an earlier version are silently ignored and cleaned up over time.

library(httr2)

# A local test server: /count increments a counter on every request that
# reaches it, and /fresh/count marks the response cacheable for 60 seconds.
shelf_api <- function(port) {
  seen <- new.env()
  seen$plain <- 0L
  seen$fresh <- 0L
  plumber::pr() |>
    plumber::pr_set_serializer(plumber::serializer_unboxed_json()) |>
    plumber::pr_get("/count", \() {
      seen$plain <- seen$plain + 1L
      list(served = seen$plain)
    }) |>
    plumber::pr_get("/fresh/count", \(res) {
      seen$fresh <- seen$fresh + 1L
      res$setHeader("Cache-Control", "max-age=60")
      list(served = seen$fresh)
    }) |>
    plumber::pr_run(host = "127.0.0.1", port = port, docs = FALSE, quiet = TRUE)
}

srv <- callr::r_bg(shelf_api, args = list(port = 8765L))
base <- "http://127.0.0.1:8765"
request(base) |>
  req_url_path_append("count") |>
  req_retry(max_tries = 20, retry_on_failure = TRUE, backoff = \(i) 0.25) |>
  req_perform() |>
  invisible()

served <- \(req) resp_body_json(req_perform(req))$served

# No cache headers: every call reaches the server (the first count was above).
plain <- request(base) |> req_url_path_append("count")
served(plain)
#> [1] 2
served(plain)
#> [1] 3

# A cacheable response with req_cache(): the second call does not.
cache_dir <- tempfile("shelf-cache")
fresh <- request(base) |>
  req_url_path_append("fresh", "count") |>
  req_cache(cache_dir)
served(fresh)
#> [1] 1
served(fresh)
#> [1] 1
length(list.files(cache_dir))
#> [1] 1

# The same request without req_cache() goes to the server again.
served(request(base) |> req_url_path_append("fresh", "count"))
#> [1] 2

# Only GET responses with status 200 and a caching header are stored: the
# plain route above has none, so a cache would store nothing for it.
cache_dir_2 <- tempfile("shelf-cache")
cached_plain <- plain |> req_cache(cache_dir_2)
invisible(served(cached_plain))
length(list.files(cache_dir_2))
#> [1] 0

invisible(srv$kill())

The route that sets Cache-Control: max-age=60 is served once: the second call with req_cache() returned served 1 again without reaching the server, and left one file in the cache directory; the same request without req_cache() reached the server (count 2). A response without a caching header, like /count, is not stored (zero files). Since httr2 1.2.3 a custom req_error() is respected on cache hits.

Testing without a server: mocking

The best test of code that calls an API does not call it. with_mocked_responses(mock, code) and local_mocked_responses(mock) replace the network call of req_perform() (and of req_perform_parallel(), req_perform_sequential() and req_perform_iterative() since httr2 1.2.0) for the duration of code or of the calling function. mock is a function from the request to a response, or a list of responses served in order. response() and response_json() build responses of any status. They were introduced in httr2 1.0.0 as the replacement for with_mock() and local_mock(), which were deprecated then and removed in 1.2.0. Responses retrieved from a mock include the originating request, as real ones do (httr2 1.2.3). local_mocked_responses() is the form used in test_that() blocks of a package (Testing with testthat); this needs no server, no port and no network.

library(httr2)

# A function that talks to a web API (a placeholder host; never contacted).
book_title <- function(isbn) {
  request("https://api.example.com/v1") |>
    req_url_path_append("books", isbn) |>
    req_perform() |>
    resp_body_json() |>
    _$title
}

# with_mocked_responses(): the mock replaces req_perform()'s network call.
# A function receives the request and returns a response.
with_mocked_responses(
  \(req) response_json(body = list(title = "Ten Winters")),
  book_title("9780013001331")
)
#> [1] "Ten Winters"

# The function sees the request, so one mock can serve several URLs.
router <- \(req) {
  if (grepl("/books/9780013001331$", req$url)) {
    response_json(body = list(title = "Ten Winters"))
  } else {
    response_json(status_code = 404L, body = list(error = "no such book"))
  }
}
with_mocked_responses(router, book_title("9780013001331"))
#> [1] "Ten Winters"
with_mocked_responses(
  router,
  tryCatch(book_title("0"), httr2_http_404 = \(e) conditionMessage(e))
)
#> [1] "HTTP 404 Not Found."

# A list is served in order, one response per request.
with_mocked_responses(
  list(
    response_json(body = list(title = "Ten Winters")),
    response_json(body = list(title = "Salt and Static"))
  ),
  c(book_title("1"), book_title("2"))
)
#> [1] "Ten Winters"     "Salt and Static"

# local_mocked_responses() lasts until the function that calls it returns:
# the form to use inside test_that() blocks.
offline_title <- function() {
  local_mocked_responses(\(req) response_json(body = list(title = "Offline")))
  book_title("anything")
}
offline_title()
#> [1] "Offline"

# response() builds any response, including a non-JSON one.
text_resp <- response(
  status_code = 200L,
  headers = list("Content-Type" = "text/plain"),
  body = charToRaw("hello")
)
hello <- \() {
  request("https://api.example.com/hello") |>
    req_perform() |>
    resp_body_string()
}
with_mocked_responses(\(req) text_resp, hello())
#> [1] "hello"

The function under test, book_title(), is the kind of wrapper a package contains. The mock sees the request, so router() served one URL and answered 404 to the others, which triggered the real error path of the code under test, httr2_http_404. One limit, seen with the checked version of httr2: a mocked 429 response is not retried by req_retry() (the call raised HTTP 429 Too Many Requests. at once), so retry logic is tested against the local server, as in the previous section, or by testing the is_transient and after functions on their own with response() objects.

OAuth, streaming and background requests

The remaining parts of httr2 need a real server or a user, so this section is prose. The req_oauth_*() functions implement the OAuth flows: req_oauth_auth_code() (opens a browser; for interactive use), req_oauth_client_credentials() and req_oauth_bearer_jwt() (server-to-server), req_oauth_device(), req_oauth_password(), req_oauth_refresh() and req_oauth_token_exchange(). They take an oauth_client() and cache the token, encrypted, in a directory (oauth_cache_path(), which defaults to a standard R user cache directory since httr2 1.3.0); tokens are refreshed automatically and oauth_cache_prune() and oauth_cache_clear() delete old ones. httr2 1.3.0 raised the default margin before expiry at which a cached token is treated as expired from 5 to 30 seconds (the expiry_margin argument) and fixed the pruning of cached tokens, which had not matched the encrypted files written to disk. The httr2 OAuth article is httr2 package documentation: OAuth. Credentials never belong in the script; use Sys.getenv() and obfuscate().

Large or open-ended responses are streamed rather than loaded: req_perform_connection() returns a response whose body is a connection, read in pieces with resp_stream_lines(), resp_stream_sse() (server-sent events) and resp_stream_bytes() (httr2 1.0.4; req_perform_stream() is superseded by it, and soft-deprecated since httr2 1.2.0). req_perform_promise() (httr2 1.0.3) returns a promise so a request can run in the background of a Shiny app or an async pipeline; the Parallel and Asynchronous Computing page puts it in context. is_online() wraps curl::has_internet(). httr2_translate() (httr2 1.2.3) and curl_translate() turn a request into the equivalent curl command and back, which is useful for copying a call from an API’s documentation.

JSON with jsonlite

JSON is the body format of most web APIs, and httr2 reads and writes it with jsonlite: req_body_json(req, data, auto_unbox = TRUE, digits = 22, null = "null") calls jsonlite::toJSON(), and resp_body_json(resp, simplifyVector = FALSE, ...) calls jsonlite::fromJSON(). The defaults differ in one important way: httr2 writes length-one vectors as JSON scalars (auto_unbox = TRUE), whereas toJSON() on its own writes them as one-element arrays, and resp_body_json() returns lists (simplifyVector = FALSE), while jsonlite::fromJSON() simplifies by default. simplifyVector = TRUE turns an array of records into a data frame, the form that data.frame code wants (the scripts above do it for the /books route); digits = 22 keeps the full double precision of numbers on the way out. The rules of simplification and the handling of NA and NULL belong to Serialization: RDS, RData and JSON. For XML and HTML bodies, resp_body_xml() and resp_body_html() use xml2.

Pitfalls

  • A download that "worked". download.file() returns 0 or a non-zero status, and with several URLs it may not raise an error at all. Test the value, and for a vector of URLs read attr(status, "retvals"). Never assume the file is there because the call returned.

  • A text-mode transfer on Windows. mode = "w" (the default) changes line endings and corrupts binary files; the damage is invisible on Linux and macOS. Always pass mode = "wb" for anything that is not text.

  • Secrets in code and in logs. A token or password belongs in an environment variable (Sys.getenv("SHELF_API_TOKEN")), a .Renviron file outside version control or a secrets store; never in a URL (https://user:password@host/ shows up in server logs) and never in a script that is committed. Printing a request is safe for Authorization and for headers marked .redact; printing req_get_headers(req, redacted = "reveal"), a saved log or curlGetHeaders() output of an authenticated URL is not.

  • Unescaped path segments and query values. URLencode() leaves reserved characters alone, so it is wrong for a query value (reserved = TRUE, or req_url_query(), which encodes for you); req_url_path_append() does not escape, so curl::curl_escape() a segment that came from data.

  • Treating a 404 as a connection error, or the reverse. HTTP errors are httr2_http_<status>; failures to connect, resolve or finish in time are httr2_failure. Handle them by class, and use req_error(body = ) so the R error carries what the API said.

  • Retrying what must not be repeated. req_retry() is safe for GET; for a POST that creates a record, check that the API is idempotent or uses an idempotency key. Without max_tries (or max_seconds) httr2 makes only two attempts.

  • Polling with Sys.sleep(). Use req_retry() with retry_on_failure = TRUE, or a loop with a limit, as the examples do; an unbounded while loop that waits for a service hangs forever when the service never comes up.

  • The default 60 seconds. options(timeout) limits the time without a response, not the whole transfer, and 60 seconds is too short for large files; set it in the script with max() so a user’s higher value stays.

  • Tests that depend on the internet. A test that calls a real API fails when the network, the API or the credentials change. Mock the responses (with_mocked_responses(), local_mocked_responses()) or serve them locally, and guard live tests with testthat::skip_if_offline() or an environment variable.

  • Reading a CSV from https:// on an old R. Before R 3.3.0 read.csv("https://...") needed a method choice on Windows; since 4.2.0 nothing needs to be done (R FAQ 7.43).

Version notes

The R rows are from R NEWS (R NEWS for R 3.x and R NEWS for R 4.x); the httr2 rows are from the package’s own NEWS (httr2 package documentation: Changelog). Only the entries relevant to this page are listed.

Version Change

R 3.2.0

libcurl is available as an optional method: capabilities("libcurl"), libcurlVersion(), curlGetHeaders(), download.file(method = "libcurl") (several URLs at once, redirections) and url(method = "libcurl"), with the option url.method

R 3.2.2

on non-Windows platforms download.file(method = "auto") chooses libcurl for https:// and ftps:// URLs when it is available

R 3.4.0

on Unix-alikes the default methods of download.file() and url() choose "libcurl" except for file:// URLs; the "wget" and "curl" methods give an R error rather than a non-zero return value when the external command fails

R 3.6.0

download.file() and url() get the argument headers for custom HTTP headers (for example basic authentication)

R 4.0.3

the option timeout can be set from R_DEFAULT_INTERNET_TIMEOUT; libcurl-based access, including curlGetHeaders(), now respects timeout

R 4.1.0

curlGetHeaders() gets the arguments timeout and TLS

R 4.1.1

ftp:// URLs are deprecated; the non-default "internal" method is deprecated for http:// and ftp://

R 4.2.0

on Windows download.file(method = "auto") and url(method = "default") use libcurl for everything except file://; the "internal" method supports only file://; "wininet" no longer supports ftp://; libcurl is required to build R from source

R 4.2.2

on Windows, R_LIBCURL_SSL_REVOKE_BEST_EFFORT switches to best-effort certificate revocation checks for the libcurl method

R 4.3.0

download.file() gives a helpful error for an invalid download.file.method option; libcurl 8.x is accepted by configure

R 4.5.0

install.packages() and download.packages() download packages simultaneously with libcurl; download.file() reports the outcome of individual files

R 4.5.2

install.packages() and download.packages() again consult download.file.method when the method is unspecified

R 4.6.0

credentials for basic authentication with the libcurl method can be provided in a netrc file named by options("netrc")

httr2 1.0.0 (2023-11-14)

req_perform_iterative(), req_perform_parallel() and req_perform_sequential(); resps_successes() and friends; all errors inherit from httr2_error (httr2_failure, httr2_http_<status>); local_mocked_responses() and with_mocked_responses() replace local_mock() and with_mock(); req_url_query(.multi = ); resp_has_body(), resp_check_content_type(); cache pruning arguments in req_cache(); resp_body_json() caches the parsed value

httr2 1.1.0 (2025-01-18)

req_retry() circuit breaking and max_tries = 2 by default; req_headers_redacted(); resp_request(); req_url_query(.space = ); url_modify() and url_parse() with curl::curl_parse_url(); req_perform_connection() no longer experimental; is_online()

httr2 1.1.1 (2025-03-08)

req_throttle() uses a token bucket; req_headers() redacts Authorization; req_dry_run() pretty-prints JSON and omits volatile headers; req_perform_parallel() supports retry, throttle, OAuth and the cache; HTTR2_VERBOSITY

httr2 1.2.0 (2025-07-13)

with_mock() and local_mock() removed; req_perform_stream() soft-deprecated for req_perform_connection(); redacted headers are no longer serialised to disk; URL construction uses curl::curl_modify_url(); req_get_url() and the other req_get_*() introspection functions; last_request_json() and last_response_json(); resp_timing(); mocks for the parallel, sequential and iterative performers

httr2 1.2.3 (2026-06-23)

req_throttle() can enforce several limits at once; httr2_translate(); mocked and cached responses carry the request; req_error() is applied on cache hits

httr2 1.3.0 (2026-07-13)

OAuth cache pruning fixed; oauth_cache_path() defaults to an R user cache directory; oauth_cache_prune(); expiry_margin (30 s by default); requires rlang 1.3.0, which changes the hash that names cached files

The NEWS of R-devel (the upcoming 4.7.0, not released) lists no change to download.file(), url(), curlGetHeaders() or URLencode().

Where older tutorials differ

Much of the advice on the web about R and HTTP predates httr2 1.0.0 (2023) and the Windows libcurl default (R 4.2.0). It still works in many cases, and it is not wrong to read it; it is a map of what replaced what.

Older tutorial Today Why Stable in

RCurl::getURL(), getForm(), postForm()

httr2 (or curl)

RCurl is a separate binding to libcurl; httr and httr2 are built on the curl package, and httr2 is the one the httr authors recommend

httr2 1.0.0

httr::GET(url), httr::content(r, "parsed"), httr::stop_for_status(r)

request(url) |> req_perform() |> resp_body_json()

httr is superseded and only maintained to stay on CRAN; httr2 raises the HTTP error for you

httr2 1.0.0

httr::add_headers(), authenticate(), config(ssl_verifypeer = 0), set_config()

req_headers(), req_auth_basic(), req_options() on one request

no global configuration: every setting is on the request value, and Authorization is redacted when printed

httr2 1.0.0, 1.1.1

httr::POST(url, body = list(...), encode = "json")

req_body_json() or req_body_form()

the body type is a function, not an argument

httr2 1.0.0

paste0(url, "?genre=", genre, "&limit=", n)

req_url_query(genre = genre, limit = n)

escaping and vectors are handled

httr2 1.0.0

a hand-written for loop around try(GET(url)) with Sys.sleep()

req_retry() and req_throttle()

honours Retry-After, backs off with jitter, one token bucket per host

httr2 1.0.0, 1.1.1

options(download.file.method = "wininet"), setInternet2(TRUE), method = "curl" to enable https

nothing: the default is libcurl

FAQ 7.43: since R 4.2.0 the libcurl method is always available and the default for HTTPS

R 3.3.0, 4.2.0

download.file(url, destfile) on Windows for a binary file

download.file(url, destfile, mode = "wb")

text mode changes line endings on Windows

always

readLines(url("http://...")) for HTTPS sites, or getURL(url, ssl.verifypeer = FALSE)

readLines(url("https://...")), with certificates verified

verification is the default and should stay on

R 3.2.2

jsonlite::fromJSON(url) for an API that needs a header or a token

resp_body_json(req_perform(req), simplifyVector = TRUE)

fromJSON(url) can send no headers and shows no error body

httr2 1.0.0

unlist(lapply(urls, GET)) with a loop for many pages

req_perform_parallel(), req_perform_iterative()

paging helpers, max_active, error strategies

httr2 1.0.0

A test that calls the real service

local_mocked_responses()

no network in tests

httr2 1.0.0

The httr package is at https://httr.r-lib.org/ and its status paragraph tells new users to use httr2; code that uses GET(), content() and stop_for_status() still runs, so read it as a description of the same ideas: a request is a URL plus configuration, a response has a status, headers and a body, and a status of 400 or more should become an error. RCurl is still on CRAN (RCurl on CRAN); its getURL() and postForm() are documented at RCurl package documentation: getURL. Neither package is run on this page.

Where to go next