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 |
read the |
301, 302 |
the resource moved |
follow the |
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 |
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 |
|
a text or CSV resource that needs no headers and no authentication |
download a file |
|
saving a file to disk, including binary files and several files at once |
inspect a URL |
|
the status and headers, without the body |
talk to an API |
httr2 ( |
methods other than |
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 whyshelf_api()takesbooksandportas arguments, passed inargs, and callsplumber::pr()rather than assuminglibrary(plumber). A closure overbooksin the parent session finds nobooksin the child, and every request answers500: this was the first error met while writing these examples. -
The port is fixed (
8765) and the host is127.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 afterbackoffseconds, up tomax_triesattempts (it is explained in the section on retries below). The base R scripts use a short loop aroundcurlGetHeaders(), which also fails until the port is open. -
The server is stopped explicitly.
srv$kill()stops the background process andsrv$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 useon.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, ...)
Source: R documentation: download.file
and its arguments are:
-
urlis a character string naming the resource, starting with a scheme such ashttp://,https://orfile://; with the"libcurl"method it may be a longer vector.destfileis the path to write, tilde-expanded, a vector of the same length whenurlis. -
methodis one of"internal","libcurl","wget","curl","wininet"(Windows only) or"auto", and when it is missing it comes fromgetOption("download.file.method"). With"auto"(the default when nothing is set) the"internal"method is used forfile://URLs and"libcurl"for all others. Today"internal"supports only thefile://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 = TRUEsuppresses the status messages and the progress bar. With the defaultFALSEa download printstrying URL ..., the content type and length, a text progress bar anddownloaded N bytes; they are messages, written to standard error. -
modeis how the file is written:"w","wb","a"or"ab". The choice matters only on Windows, where a text-mode transfer changes\nline endings to\r\nand 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 usemode = "wb"for everything that is not text. (On Windows, whenmodeis missing and the URL ends in.gz,.bz2,.xz,.tgz,.zip,.jar,.rda,.rds,.RDataor.pdf, R chooses"wb"itself.) -
cacheOK = FALSEasks for a copy from the site rather than from an intermediate cache, forhttp://andhttps://URLs. -
extrais for the"wget"and"curl"methods only.headersis a named character vector of extra HTTP headers forhttp://andhttps://URLs (added in R 3.6.0); theUser-Agentheader comes from theHTTPUserAgentoption and is sent first. -
The value is an invisible integer:
0for success and non-zero for failure. When several files are downloaded at once and at least one succeeds, it is0with 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())
Source: R documentation: download.file, R documentation: capabilities and curl package documentation: curl_download
Reading the output from the top:
-
capabilities("libcurl")isTRUE. -
The first download returned
0and wrote a file, whichread.csv()turns back into an identicalbooksand whose MD5 sum equals that ofbooks.csv: the server delivered the bytes unchanged. The path of the temporary file is never printed, becausetempfile()returns a path under the session’s temporary directory that differs from run to run; the script printsfile.exists()and a comparison instead. -
With
quiet = FALSEthe 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.0install.packages()anddownload.packages()started using it to download packages simultaneously, anddownload.file()reports the outcome for individual files (R NEWS). -
A
file://URL has an empty host field, so a local file isfile://followed by an absolute path that starts with/: three slashes in all on a Unix-alike. The script builds it withpaste0("file://", normalizePath("books.csv"))and prints only its first eight characters, because the rest is the absolute path of this machine. R handlesfile://URLs with its own code whatever themethodis, does not decode percent-encoded characters in them (callURLdecode()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. Itshandleargument takes acurl::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
404produced 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 (unlessmodeis an append mode, when it is left unchanged). -
With several URLs, one failure is not an error. The call returned
0withretvalsof0 1and two warnings (cannot open URL ...andsome files were not downloaded), and the good file was written. A script that downloads several files has to readattr(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 '...'fromdownload.file()andcannot open the connection to '...'fromreadLines(url()).curlGetHeaders()raises a single error whose text starts withlibcurl error code 7:; the script masks the number of milliseconds in it (after N ms) because it varies. -
The
timeoutoption 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())
Source: R documentation: connections, R documentation: curlGetHeaders and jsonlite package documentation: fromJSON
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)andread.csv(url(url_string))give the same data frame; the first is the usual way to write it. The same holds forscan(),readLines()andread.table(). -
The
headersargument ofurl()(R 3.6.0 and later) is how a request header reaches the server; the local/whoamiroute echoes the custom header back as JSON.readLines()of a response with no final newline warns "incomplete final line";warn = FALSEsilences it. -
jsonlite::fromJSON()accepts a URL directly and returns a data frame for a JSON array of records. It is only suitable for a plainGET; 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 thatlibcurlVersion()contains"Schannel"); otherwise set the environment variableCURL_CA_BUNDLEto the path of a certificate bundle file such asca-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 toTRUEit makes the revocation check best-effort, "with the consequence of reducing security" (?download.fileand 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 withreq_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.filenameshttp_proxyandftp_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, sohttps_proxyapplies tohttps://URLs,all_proxyis the fallback andno_proxylists hosts that bypass the proxy (libcurl tutorial). Set them in.Renvironor in the shell, not in code. httr2 hasreq_proxy(url, port, username, password, auth)for a proxy that applies to one request. -
Timeouts and the user agent.
options(timeout)andR_DEFAULT_INTERNET_TIMEOUTwere described above. TheHTTPUserAgentoption sets the user agent string used bydownload.file(),url()andcurlGetHeaders(); 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)
Source: R documentation: download.file
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 |
|
shape the URL |
|
shape the message |
|
authenticate |
|
behaviour |
|
inspect without sending |
|
send |
|
read |
|
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"
Source: httr2 package documentation: request, req_url_query and req_headers
What the script shows:
-
The request is a value.
req_url_path_append("books")andreq_url_query(...)return new requests;reqis unchanged.req_get_url(),req_get_method()andreq_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 nextreq_url_query()fails to parse the URL withFailed 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 withcurl::curl_escape()first. Query values, on the other hand, are escaped byreq_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 replacedAccept, keeping the new spellingaccept)..redactnames headers whose values must not be shown when the request is printed.req_get_headers()leaves the redacted ones out unless you askredacted = "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 theUser-Agent; without it httr2 sendshttr2/<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 itPOSTby default.req_timeout(seconds)sets the total time; the printed request shows the libcurl options (timeout_ms,connecttimeout) it set. Since httr2 1.0.1req_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, andreq_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"
Source: httr2 package documentation: req_dry_run, req_body_json, req_auth_bearer_token and req_auth_basic
Notes on this script:
-
Bodies.
req_body_json(data)writes the list with jsonlite (scalars unboxed by default:auto_unbox = TRUE), setsContent-Type: application/jsonand makes the methodPOST;req_body_form(...)sendsapplication/x-www-form-urlencoded;req_body_multipart()sendsmultipart/form-datafor file uploads,req_body_file()andreq_body_raw()send a file or bytes as they are.req_method()after the body overrides the method (the form example is aPATCH). 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"))addsAuthorization: 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)addsAuthorization: Basic <base64>and should be used only withhttps://. -
The Authorization header is redacted when the request is printed and in the dry run, as the output shows:
Authorization: <REDACTED>andauthorization: <REDACTED>. httr2 redactsAuthorizationautomatically inreq_headers()and thereq_auth_*()functions (httr2 1.1.1) andstr()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 withsaveRDS()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()andsecret_encrypt_file()keep a secret out of source code; thereq_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())
Source: httr2 package documentation: req_perform, resp_status, resp_headers, resp_body_raw and last_response
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, andresp_content_type()the media type without parameters such ascharset. -
resp_header(resp, name)returns one header as a string orNULL; it ignores the case of the name, anddefault =supplies a value for a missing header.resp_headers(resp, filter = regex)returns anhttr2_headerslist, filtered by a regular expression. TheDateheader 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 = TRUEgives vectors and data frames, extra arguments go tojsonlite::fromJSON()), andresp_body_html()andresp_body_xml()(the xml2 package).resp_has_body()tells whether there is one. Since httr2 1.0.0resp_body_json()andresp_body_xml()cache what they parsed, so calling them repeatedly is cheap. -
The content type is checked.
resp_body_json()on atext/csvresponse fails withUnexpected content type "text/csv"andExpecting 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 withread.csv(text = ...). -
resp_request(resp)returns the request that produced a response (httr2 1.1.0),resp_url(resp)the final URL after redirects, andresp_timing(resp)the timing libcurl measured (httr2 1.2.0).last_request()andlast_response()return the most recent request and response, including a failed one, andlast_response_json()shows a JSON body (httr2 1.2.0; with content types ending in+jsonfrom httr2 1.2.3). -
A
POSTwith a JSON body returned201and aLocationheader; the plumber route parsed the body intoreq$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()andwith_verbosity()print the traffic (header_req,header_resp,body_req,body_resp,info), withAuthorizationredacted; the environment variableHTTR2_VERBOSITYsets 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:
-
The default message is
HTTP 404 Not Found.with no word of what the API said; the classes arehttr2_http_404,httr2_http,httr2_error, then the genericrlang_error,errorandcondition. -
tryCatch()with a handler named after a class handles that class:httr2_http_404only for a 404,httr2_httpfor any HTTP error status (the 500 is handled by the second). Order matters when a more specific class comes first. -
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. -
req_error(body = f)callsf(resp)on an error response and appends the character vector it returns as extra bullets to the message. The API saidthe 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 usereq_error(), and the pattern of the httr2 article on wrapping APIs (httr2 package documentation: Wrapping APIs). -
req_error(is_error = f)decides what counts as an error:\(resp) FALSEturns the 404 into an ordinary response, which can then be tested withresp_is_error()andresp_status_desc().resp_check_status()raises the error afterwards if you want it (HTTP 404 Not Found., classhttr2_http_404). -
The last request and response remain available.
-
A
401is an HTTP error like the others. With the dummy bearer token from the environment the same request succeeds. -
A timeout is a failure, not a status.
req_timeout(0.3)against the one-second route gives classhttr2_failure; the message isFailed to perform HTTP request., thenCaused by error in curl::curl_fetch_memory():, then libcurl’sTimeout 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 classcurl_error_operation_timedout. -
A server that is gone is a failure too, with the parent class
curl_error_couldnt_connectand the messageCouldn't connect to server. The script catches it withtryCatch(error = identity);httr2_failurecan 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())
Source: httr2 package documentation: req_retry and req_throttle
Reading the output:
-
Without
req_retry()the429is an ordinary HTTP error, classhttr2_http_429. -
With
req_retry(max_tries = 4)the same request succeeded on the third attempt: the server counted three requests (attemptis 3) and answered429withRetry-After: 1for 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). Areq_retry()that gives up raises the error of the last response: withmax_tries = 2against the route that never succeeds, the error isHTTP 429 Too Many Requests., classhttr2_http_429, andresp_retry_after(err$resp)reads the header,1. -
after = retry_afteris the same wait written out: it readsRetry-Afteras a number and returnsNAwhen the header is absent, which makes httr2 usebackoff. Many APIs report the limit in other headers (GitHub usesX-RateLimit-Reset);afteris how to read them, andis_transientis how to say that a 403 withX-RateLimit-Remaining: 0is a rate limit (the examples in?req_retry). -
is_transientreplaced the default test: the/flakyroute failed once with a500; without it the call errors withHTTP 500 Internal Server Error., with it the second attempt succeeds. Only retry what is safe to repeat: aGETis, aPOSTthat creates something may not be. -
Throttling.
req_throttle()is a token bucket (httr2 1.1.1): the bucket holds at mostcapacitytokens and refills overfill_time_sseconds, every request takes a token, and an empty bucket makes the request wait.capacity = 2, fill_time_s = 1means two requests at once and then one every half second, so five requests took more than one second. The older argumentrateis still accepted:ratetimesfill_time_s(60 s by default) becomes the capacity, sorate = 2 / 1is 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 samerealm, by default the host name; since httr2 1.2.2req_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 ofreq_throttle()andreq_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())
Source: httr2 package documentation: req_perform_iterative, iterate_with_offset, req_perform_parallel and resps_successes
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()returns0or 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 readattr(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 passmode = "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.Renvironfile 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 forAuthorizationand for headers marked.redact; printingreq_get_headers(req, redacted = "reveal"), a saved log orcurlGetHeaders()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, orreq_url_query(), which encodes for you);req_url_path_append()does not escape, socurl::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 arehttr2_failure. Handle them by class, and usereq_error(body = )so the R error carries what the API said. -
Retrying what must not be repeated.
req_retry()is safe forGET; for aPOSTthat creates a record, check that the API is idempotent or uses an idempotency key. Withoutmax_tries(ormax_seconds) httr2 makes only two attempts. -
Polling with
Sys.sleep(). Usereq_retry()withretry_on_failure = TRUE, or a loop with a limit, as the examples do; an unboundedwhileloop 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 withmax()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 withtestthat::skip_if_offline()or an environment variable. -
Reading a CSV from
https://on an old R. Before R 3.3.0read.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: |
R 3.2.2 |
on non-Windows platforms |
R 3.4.0 |
on Unix-alikes the default methods of |
R 3.6.0 |
|
R 4.0.3 |
the option |
R 4.1.0 |
|
R 4.1.1 |
|
R 4.2.0 |
on Windows |
R 4.2.2 |
on Windows, |
R 4.3.0 |
|
R 4.5.0 |
|
R 4.5.2 |
|
R 4.6.0 |
credentials for basic authentication with the libcurl method can be provided in a netrc file named by |
httr2 1.0.0 (2023-11-14) |
|
httr2 1.1.0 (2025-01-18) |
|
httr2 1.1.1 (2025-03-08) |
|
httr2 1.2.0 (2025-07-13) |
|
httr2 1.2.3 (2026-06-23) |
|
httr2 1.3.0 (2026-07-13) |
OAuth cache pruning fixed; |
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 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 is superseded and only maintained to stay on CRAN; httr2 raises the HTTP error for you |
httr2 1.0.0 |
|
|
no global configuration: every setting is on the request value, and |
httr2 1.0.0, 1.1.1 |
|
|
the body type is a function, not an argument |
httr2 1.0.0 |
|
|
escaping and vectors are handled |
httr2 1.0.0 |
a hand-written |
|
honours |
httr2 1.0.0, 1.1.1 |
|
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 |
|
|
text mode changes line endings on Windows |
always |
|
|
verification is the default and should stay on |
R 3.2.2 |
|
|
|
httr2 1.0.0 |
|
|
paging helpers, |
httr2 1.0.0 |
A test that calls the real service |
|
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
-
Serialization: RDS, RData and JSON for what
jsonlite::fromJSON()andtoJSON()do with data frames,NAandNULL. -
Connections and the File System for
url()as one of the connection types,on.exit(close(con))and temporary files. -
Reading and Writing Text Data for
read.csv()arguments once the bytes have arrived. -
Conditions and Error Handling for
tryCatch()andwithCallingHandlers()with the httr2 condition classes. -
Shiny, plumber and Web APIs for writing the server side that this page used only as a test double.
-
Testing with testthat for mocking and skipping tests that need a network.
-
Parallel and Asynchronous Computing for background R processes (callr, future, mirai) and promises.
-
Sessions, Workspaces and Startup for
.Renviron, environment variables and options. -
Defensive Programming and Input Validation for checking what an API returns before using it.
References
-
R Data Import/Export: 8 Network interfaces; 8.1 Reading from sockets; 8.2 Using download.file
-
R FAQ 7.43: How can I enable secure https downloads in R?
-
options (timeout, HTTPUserAgent, internet.info, netrc, download.file.method)
-
R NEWS: Changes in R 4.0.3 to 4.6.0; R 3.x NEWS: Changes in R 3.2.0 to 3.6.0
-
httr2 package documentation: request, req_url_query and req_url_path_append, req_headers, req_body_json and req_body_form, req_auth_bearer_token, req_auth_basic, req_dry_run, req_perform, resp_status, resp_headers, resp_body_json and resp_body_raw, last_request and last_response, req_error, req_timeout, req_retry, req_throttle, req_perform_iterative, iterate_with_offset, req_perform_parallel, resps_successes, req_cache, with_mocked_responses, response, url_parse, Wrapping APIs, OAuth, Changelog
-
plumber package documentation: pr, pr_handle and pr_get, pr_run
-
httr package documentation (superseded) and RCurl on CRAN
-
libcurl tutorial (proxies), as referenced from the download.file help page