| Title: | Normalized HTTP Transport with Circuit Breaking and Caching |
|---|---|
| Description: | Web service calls return a normalized result value instead of raising a condition, so a caller branches on data rather than on an error handler. Transport failure, a non-success status code, and an unreadable response body are reported as three distinct outcomes. Per-host circuit breaking, retry with a transient-failure predicate, optional throttling, redacted request headers, and a success-only cache come as defaults. Many questions to one source can be asked as a single batch, where only the entries the cache is missing reach the network. Service-specific knowledge is left to the client packages built on top. The circuit breaker is the pattern described in Nygard (2018, ISBN:9781680502398). |
| Authors: | Samuel Bharti [aut, cre, cph] (ORCID: <https://orcid.org/0000-0003-4190-7058>), Barret Schloerke [ths] (ORCID: <https://orcid.org/0000-0001-9986-114X>), Carson Sievert [ths] (ORCID: <https://orcid.org/0000-0002-4958-2844>), Posit Software, PBC [cph, fnd] (ROR: <https://ror.org/03wc8by49>) |
| Maintainer: | Samuel Bharti <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 0.1.3 |
| Built: | 2026-09-16 17:14:13 UTC |
| Source: | https://github.com/samuelbharti/biohttp |
Deprecated. Scheduled for removal in 0.3.0.
Translates an envelope() into the four-field shape a hand-rolled HTTP layer
commonly returns:
list(ok = TRUE, status = 200L, data = <parsed>, error = NULL)
detail is carried through as a fifth field, because call sites that log it
would otherwise break on adoption.
as_legacy_envelope(res)as_legacy_envelope(res)
res |
An envelope from |
A list with ok, status (the HTTP code), data, error, and
detail.
The old shape cannot express two things the new one can. It has no way to say "skipped because the host's breaker was open" as distinct from "failed", and it collapses the user-facing sentence and the log detail into one field. Both are load-bearing, and neither can be reconstructed once a call site is written against the old shape.
Note the collision in the word status: here it is the HTTP code, an
integer, and in an envelope() it is the enum. That is exactly the ambiguity
the new contract removes.
Scheduled for removal in 0.3.0. Use it to land a migration with a reviewable diff, then delete the call sites and delete this.
Warns once per session, not once per call, so a migration loop stays readable.
old <- as_legacy_envelope(status_ok(data = list(n = 1), source = "MyGene")) old$ok old$statusold <- as_legacy_envelope(status_ok(data = list(n = 1), source = "MyGene")) old$ok old$status
For a client that wants the body and does not care why a call failed. Every other field is still there for a client that does care, so reach for this when the failure is genuinely not actionable, not to avoid reading the envelope.
body_or_null(res)body_or_null(res)
res |
An envelope from |
Returns the body for ok and for stale. A stale envelope carries real
data that is merely past its freshness window, so returning NULL for it
would throw away the one thing the caller asked for. Branch on res$status
if the difference matters. Note that res$ok is FALSE for stale, which
is what keeps cached() from storing it.
res$data when the status is ok or stale, otherwise NULL.
body_or_null(status_ok(data = list(n = 1))) body_or_null(status_stale(data = list(n = 1))) body_or_null(status_error(source = "gnomAD"))body_or_null(status_ok(data = list(n = 1))) body_or_null(status_stale(data = list(n = 1))) body_or_null(status_error(source = "gnomAD"))
Open means too many consecutive transport failures and still inside the
cooldown. While open, perform() short-circuits to a skipped envelope
instead of waiting on a request that is likely to fail anyway.
breaker_open(host, now = as.numeric(Sys.time()))breaker_open(host, now = as.numeric(Sys.time()))
host |
A hostname, as returned by parsing a request URL. |
now |
The current time in seconds since the epoch. Exposed so a test can drive the clock rather than sleep. |
A single logical.
breaker_reset() breaker_open("gnomad.broadinstitute.org")breaker_reset() breaker_open("gnomad.broadinstitute.org")
reachable means an HTTP response came back at all, whatever its status. A
reachable call clears the count; the Kth consecutive unreachable one trips
the breaker for the cooldown window.
breaker_record(host, reachable, now = as.numeric(Sys.time()))breaker_record(host, reachable, now = as.numeric(Sys.time()))
host |
A hostname. |
reachable |
Whether an HTTP response arrived. |
now |
The current time in seconds since the epoch. |
Pass reachable = TRUE for any response in hand, including a 5xx and
including a 2xx whose body will not parse. Only a genuine transport failure
is FALSE.
The host's new breaker state, invisibly.
breaker_reset() breaker_record("example.org", reachable = FALSE) breaker_record("example.org", reachable = TRUE) breaker_open("example.org")breaker_reset() breaker_record("example.org", reachable = FALSE) breaker_record("example.org", reachable = TRUE) breaker_open("example.org")
Forgets every host's failure count and cooldown. For tests, and for a manual "try again" once a source is known to be back.
breaker_reset()breaker_reset()
NULL, invisibly.
breaker_reset()breaker_reset()
The cachem object backing cached(). Memory-only unless
BIOHTTP_CACHE_DISK asks for the disk tier, in which case the two are
layered. Built on first use.
cache()cache()
It is safe to hold the returned object. cache_reset() clears it in place
rather than replacing it, unless a cache setting has changed, in which case a
differently configured store genuinely has to be built. See cache_reset().
A cachem cache object.
cache_reset() class(cache())[1]cache_reset() class(cache())[1]
A stable lowercase-hex hash. cachem keys may contain only lowercase
letters and numbers, so a URL cannot be one directly and the inputs are
hashed instead. rlang::hash() returns lowercase hex, which satisfies the
rule.
cache_key(source, key, params = NULL)cache_key(source, key, params = NULL)
source |
A friendly label for the service. |
key |
Something identifying the call, usually the method and URL. |
params |
Anything else that changes the answer, such as a POST body. |
The salt and the schema version are inside the hash, so changing either
yields a fresh keyspace. Set BIOHTTP_CACHE_SALT per deployment: a shared
disk cache would otherwise collide across app versions, and it would leak
which queries were run to anyone able to probe it.
Put anything that changes the answer into params, credentials
included. The wrappers pass their headers through, because two callers
hitting the same URL with different tokens can legitimately get different
responses, and keying on the URL alone would serve one caller's data to the
other. The cost is that rotating a token misses the cache once, which is the
right trade.
A single string.
cache_key("gnomAD", "GET https://example.org/v1/gene/BRCA1") cache_key("gnomAD", "POST https://example.org/graphql", list(q = "BRCA1"))cache_key("gnomAD", "GET https://example.org/v1/gene/BRCA1") cache_key("gnomAD", "POST https://example.org/graphql", list(q = "BRCA1"))
Removes every entry. Which of two things happens underneath depends on whether the cache settings have changed since the store was built:
cache_reset()cache_reset()
The existing store is cleared in place, so a
reference taken from cache() earlier stays valid and keeps pointing at
the live cache. This is the ordinary case.
The store is dropped so the next cache() builds
one from the new settings. Tests rely on this after pointing
BIOHTTP_CACHE_DIR at a fresh tempdir or toggling
BIOHTTP_CACHE_DISK.
The in-place branch exists because dropping unconditionally silently orphans any held reference: writes through it go somewhere nothing else can see, and the only symptom is a hit rate quietly falling to zero. A reference held across a genuine settings change is still orphaned, but nothing changes cache settings mid-run except a test suite, and those do not hold references.
NULL, invisibly.
held <- cache() cache_reset() identical(held, cache())held <- cache() cache_reset() identical(held, cache())
Returns a cached result for key if there is one, otherwise runs fetch()
and stores the result only when it succeeded.
cached(key, fetch, ttl = NULL)cached(key, fetch, ttl = NULL)
key |
A key from |
fetch |
A function of no arguments returning an envelope. |
ttl |
Seconds a stored success stays fresh. |
A failure is never cached. This is the single most important rule in the package. A cache that stores an error fallback poisons itself for the life of the R process, and every later lookup then serves the stored failure instead of retrying. Storing only successes means a transient outage resolves itself the moment the source comes back.
The envelope, from the cache or from fetch().
cache_reset() key <- cache_key("demo", "GET /x") cached(key, function() status_ok(data = list(n = 1), source = "demo")) # A failure runs fetch() again every time. bad <- cache_key("demo", "GET /y") cached(bad, function() status_error(source = "demo")) # A bulk table that is worth keeping for a day. big <- cache_key("demo", "GET_TEXT /table.tsv") cached(big, function() status_ok(data = "a\tb\n"), ttl = 86400)cache_reset() key <- cache_key("demo", "GET /x") cached(key, function() status_ok(data = list(n = 1), source = "demo")) # A failure runs fetch() again every time. bad <- cache_key("demo", "GET /y") cached(bad, function() status_error(source = "demo")) # A bulk table that is worth keeping for a day. big <- cache_key("demo", "GET_TEXT /table.tsv") cached(big, function() status_ok(data = "a\tb\n"), ttl = 86400)
For a failure with no HTTP response at all. A DNS failure or a refused
connection is an error; only an actual timeout is a timeout, because the
two read very differently to a user deciding whether to retry.
classify_condition(cond)classify_condition(cond)
cond |
A condition caught from a failed request. |
One of STATUS_LEVELS.
classify_condition(simpleError("Timeout was reached")) classify_condition(simpleError("Could not resolve host"))classify_condition(simpleError("Timeout was reached")) classify_condition(simpleError("Could not resolve host"))
Only the codes the transport treats as an outcome in themselves are
classified. A 2xx is the caller's to interpret, since a 200 carrying an empty
result may still be no_data once parsed.
classify_http(http)classify_http(http)
http |
An HTTP status code. |
One of STATUS_LEVELS.
classify_http(404L) classify_http(429L) classify_http(503L)classify_http(404L) classify_http(429L) classify_http(503L)
The single return shape for the whole package. ok is derived from status
rather than passed in, so the two can never disagree.
envelope( status, data = NULL, source = "API", http = NA_integer_, error = NULL, detail = NULL, retry_after = NA_real_ )envelope( status, data = NULL, source = "API", http = NA_integer_, error = NULL, detail = NULL, retry_after = NA_real_ )
status |
One of STATUS_LEVELS. |
data |
The parsed body on success, |
source |
A friendly label for the service, used in the user-facing
message. For example |
http |
The HTTP status code, or |
error |
One sentence fit to show a user. Never carries technical detail. |
detail |
The technical cause, for a log. Never shown to a user. |
retry_after |
Seconds a |
A client that only wants the parsed body can use body_or_null() instead of
reading the fields directly.
A list with ok, status, http, data, source, error,
detail, retry_after, and ts.
envelope("ok", data = list(symbol = "BRCA1"), source = "MyGene", http = 200L) envelope("error", source = "gnomAD", http = 503L)envelope("ok", data = list(symbol = "BRCA1"), source = "MyGene", http = 200L) envelope("error", source = "gnomAD", http = 503L)
Assembles the request, applies req_defaults(), performs it, and caches the
result if it succeeded.
get_json( base_url, path = NULL, query = list(), source = "API", timeout = 15, max_tries = 3, headers = NULL, throttle = NULL, secret_query = NULL, ttl = NULL )get_json( base_url, path = NULL, query = list(), source = "API", timeout = 15, max_tries = 3, headers = NULL, throttle = NULL, secret_query = NULL, ttl = NULL )
base_url |
The service's base URL. |
path |
An optional path appended to |
query |
A named list of query parameters. Blank values are dropped. |
source |
A friendly label for the service. |
timeout |
Seconds before the request is abandoned. |
max_tries |
Total attempts, including the first. |
headers |
A named list of headers, all marked sensitive. |
throttle |
An optional throttle spec. See |
secret_query |
A named list of query-string credentials, for a service
that has no header form. Deliberately not part of the cache key, so it
suits a credential that raises a rate limit and not one that changes the
response. See |
ttl |
Seconds a cached success stays fresh. |
An envelope. See envelope().
# Mocked so the example runs offline. A real call drops the # with_mocked_responses() wrapper. cache_reset() breaker_reset() httr2::with_mocked_responses( list(httr2::response( status_code = 200, headers = list(`content-type` = "application/json"), body = charToRaw('{"hits":[{"symbol":"BRCA1"}]}') )), get_json( "https://mygene.info/v3", path = "query", query = list(q = "BRCA1", species = "human"), source = "MyGene" )$data$hits[[1]]$symbol )# Mocked so the example runs offline. A real call drops the # with_mocked_responses() wrapper. cache_reset() breaker_reset() httr2::with_mocked_responses( list(httr2::response( status_code = 200, headers = list(`content-type` = "application/json"), body = charToRaw('{"hits":[{"symbol":"BRCA1"}]}') )), get_json( "https://mygene.info/v3", path = "query", query = list(q = "BRCA1", species = "human"), source = "MyGene" )$data$hits[[1]]$symbol )
The batched counterpart to get_json(). Assembles one request per entry in
queries, serves whatever the cache already holds, performs only the rest,
and caches the successes.
get_json_many( base_url, path = NULL, queries = list(), source = "API", timeout = 15, max_tries = 3, headers = NULL, throttle = NULL, max_active = 6, progress = FALSE, secret_query = NULL, ttl = NULL )get_json_many( base_url, path = NULL, queries = list(), source = "API", timeout = 15, max_tries = 3, headers = NULL, throttle = NULL, max_active = 6, progress = FALSE, secret_query = NULL, ttl = NULL )
base_url |
The service's base URL. |
path |
A path appended to |
queries |
A list of named lists, one per request. Blank values are dropped from each. |
source |
A friendly label for the service, used in the user-facing
message. For example |
timeout |
Seconds before a request is abandoned. |
max_tries |
Total attempts, including the first. A retry pass
re-sends only the entries that came back |
headers |
A named list of headers, all marked sensitive. |
throttle |
A throttle spec. See |
max_active |
Maximum requests in flight at once. |
progress |
Passed to |
secret_query |
A named list of query-string credentials, applied to
every request in the batch at dispatch. See |
ttl |
Seconds a cached success stays fresh, applied to every entry in
the batch. |
Send requests for one host, and pass a throttle. perform_many() explains
both.
A list of envelopes, the same length and order as queries.
cache_reset() breaker_reset() httr2::with_mocked_responses( function(req) { httr2::response( status_code = 200, headers = list(`content-type` = "application/json"), body = charToRaw('{"hits":[]}') ) }, length(get_json_many( "https://mygene.info/v3", path = "query", queries = list(list(q = "BRCA1"), list(q = "TP53")), source = "MyGene" )) )cache_reset() breaker_reset() httr2::with_mocked_responses( function(req) { httr2::response( status_code = 200, headers = list(`content-type` = "application/json"), body = charToRaw('{"hits":[]}') ) }, length(get_json_many( "https://mygene.info/v3", path = "query", queries = list(list(q = "BRCA1"), list(q = "TP53")), source = "MyGene" )) )
For a source that ships a bulk flat file, CSV or TSV, rather than a
per-record JSON API. Same timeout, retry, caching, and header redaction as
get_json(); the body comes back verbatim as a string in data.
get_text( base_url, path = NULL, query = list(), source = "API", timeout = 30, max_tries = 3, headers = NULL, throttle = NULL, secret_query = NULL, ttl = NULL )get_text( base_url, path = NULL, query = list(), source = "API", timeout = 30, max_tries = 3, headers = NULL, throttle = NULL, secret_query = NULL, ttl = NULL )
base_url |
The service's base URL. |
path |
An optional path appended to |
query |
A named list of query parameters. Blank values are dropped. |
source |
A friendly label for the service. |
timeout |
Seconds before the request is abandoned. |
max_tries |
Total attempts, including the first. |
headers |
A named list of headers, all marked sensitive. |
throttle |
An optional throttle spec. See |
secret_query |
A named list of query-string credentials, for a service
that has no header form. Deliberately not part of the cache key, so it
suits a credential that raises a rate limit and not one that changes the
response. See |
ttl |
Seconds a cached success stays fresh. |
The success-only cache means the file is fetched once per URL and every later
lookup against it is served in process. A bulk table is usually worth keeping
longer than a per-record answer, so pass a ttl when the configured default
is too short for it.
An envelope whose data is a single string on success.
cache_reset() breaker_reset() httr2::with_mocked_responses( list(httr2::response( status_code = 200, body = charToRaw("gene\tclassification\nBRCA1\tDefinitive\n") )), get_text( "https://search.clinicalgenome.org", path = "kb/gene-validity/download", source = "ClinGen" )$data )cache_reset() breaker_reset() httr2::with_mocked_responses( list(httr2::response( status_code = 200, body = charToRaw("gene\tclassification\nBRCA1\tDefinitive\n") )), get_text( "https://search.clinicalgenome.org", path = "kb/gene-validity/download", source = "ClinGen" )$data )
The batched counterpart to get_text(), for a source that ships several
flat files rather than one. Each body comes back verbatim as a string in
data, so the envelope shape matches the single call and a caller does not
branch on which wrapper it used.
get_text_many( base_url, path = NULL, queries = list(), source = "API", timeout = 30, max_tries = 3, headers = NULL, throttle = NULL, max_active = 6, progress = FALSE, secret_query = NULL, ttl = NULL )get_text_many( base_url, path = NULL, queries = list(), source = "API", timeout = 30, max_tries = 3, headers = NULL, throttle = NULL, max_active = 6, progress = FALSE, secret_query = NULL, ttl = NULL )
base_url |
The service's base URL. |
path |
A path appended to |
queries |
A list of named lists, one per request. Blank values are dropped from each. |
source |
A friendly label for the service, used in the user-facing
message. For example |
timeout |
Seconds before a request is abandoned. |
max_tries |
Total attempts, including the first. A retry pass
re-sends only the entries that came back |
headers |
A named list of headers, all marked sensitive. |
throttle |
A throttle spec. See |
max_active |
Maximum requests in flight at once. |
progress |
Passed to |
secret_query |
A named list of query-string credentials, applied to
every request in the batch at dispatch. See |
ttl |
Seconds a cached success stays fresh, applied to every entry in
the batch. |
The keys are built exactly the way get_text() builds its own, so a batch
reuses a file a single call already fetched and the other way around.
Send requests for one host, and pass a throttle. perform_many() explains
both.
A list of envelopes, the same length and order as queries, each
with a single string in data on success.
cache_reset() breaker_reset() httr2::with_mocked_responses( function(req) { httr2::response( status_code = 200, body = charToRaw("gene\tscore\n") ) }, length(get_text_many( "https://search.clinicalgenome.org", path = c("kb/gene-validity/download", "kb/dosage/download"), queries = list(list(), list()), source = "ClinGen" )) )cache_reset() breaker_reset() httr2::with_mocked_responses( function(req) { httr2::response( status_code = 200, body = charToRaw("gene\tscore\n") ) }, length(get_text_many( "https://search.clinicalgenome.org", path = c("kb/gene-validity/download", "kb/dosage/download"), queries = list(list(), list()), source = "ClinGen" )) )
GraphQL reports query errors in the body of an HTTP 200, as a top-level
errors array, so a 2xx status is not enough to call a call successful.
graphql_error(res, source = "API")graphql_error(res, source = "API")
res |
An envelope from |
source |
A friendly label for the service. |
Returns an error envelope for either kind of failure, transport or query, so a GraphQL client collapses the two checks into one:
res <- post_json(url, body, source = "gnomAD") bad <- graphql_error(res, "gnomAD") if (!is.null(bad)) return(bad)
The failing envelope, or NULL when the call genuinely succeeded.
clean <- status_ok(data = list(data = list(gene = "BRCA1")), source = "G") graphql_error(clean, "G") queried <- status_ok(data = list(errors = list(list(message = "bad")))) graphql_error(queried, "G")$statusclean <- status_ok(data = list(data = list(gene = "BRCA1")), source = "G") graphql_error(clean, "G") queried <- status_ok(data = list(errors = list(list(message = "bad")))) graphql_error(queried, "G")$status
Works out which status the failure is and asks status_message() for the
wording, so it honours biohttp.status_message like everything else. Pass
http for a response that arrived, or condition for a transport failure
where none did.
http_error_message(source, http = NA_integer_, condition = NULL)http_error_message(source, http = NA_integer_, condition = NULL)
source |
A friendly label for the service, for example |
http |
An HTTP status code, or |
condition |
A caught condition, used only when |
A single string.
http_error_message("gnomAD", http = 404L) http_error_message("gnomAD", http = 503L) http_error_message("gnomAD", condition = simpleError("Timeout was reached"))http_error_message("gnomAD", http = 404L) http_error_message("gnomAD", http = 503L) http_error_message("gnomAD", condition = simpleError("Timeout was reached"))
TRUE for NULL, a zero-length value, NA, or a string that is empty once
trimmed. Used to drop empty query parameters so a request never sends
param= with nothing after it.
is_blank(x)is_blank(x)
x |
Any value. |
A single logical.
is_blank(NULL) is_blank(" ") is_blank("gnomAD")is_blank(NULL) is_blank(" ") is_blank("gnomAD")
A 429 and the standard 5xx codes are transient. Everything else is a settled answer, and retrying it just spends someone else's capacity.
is_transient(resp)is_transient(resp)
resp |
An httr2 response. |
A single logical.
is_transient(httr2::response(status_code = 503)) is_transient(httr2::response(status_code = 404))is_transient(httr2::response(status_code = 503)) is_transient(httr2::response(status_code = 404))
Never raises on an HTTP or a parse failure. Returns an envelope for every
outcome, so a caller branches on res$status and writes no tryCatch() of
its own.
perform(req, source = "API", secret_query = NULL)perform(req, source = "API", secret_query = NULL)
req |
An httr2 request, prepared with |
source |
A friendly label for the service, used in the user-facing
message. For example |
secret_query |
A named list of query-string credentials. Appended here
rather than by the caller, so |
Short-circuits to a skipped envelope without sending anything when the
host's breaker is open. See breaker_open().
The request must have been through req_defaults(), which disarms httr2's
error raising. Without that, req_perform() throws on a non-2xx and a
perfectly reachable host is recorded as a transport failure.
An envelope. See envelope().
breaker_reset() req <- req_defaults(httr2::request("https://mock.test/x")) httr2::with_mocked_responses( list(httr2::response( status_code = 200, headers = list(`content-type` = "application/json"), body = charToRaw('{"symbol":"BRCA1"}') )), perform(req, "MyGene")$data$symbol )breaker_reset() req <- req_defaults(httr2::request("https://mock.test/x")) httr2::with_mocked_responses( list(httr2::response( status_code = 200, headers = list(`content-type` = "application/json"), body = charToRaw('{"symbol":"BRCA1"}') )), perform(req, "MyGene")$data$symbol )
For many questions to one source. Returns a list of envelopes the same length
as reqs and in the same order, so a caller zips the results back onto
whatever it asked about by position.
perform_many( reqs, source = "API", max_active = 6, progress = FALSE, secret_query = NULL, max_tries = 3 )perform_many( reqs, source = "API", max_active = 6, progress = FALSE, secret_query = NULL, max_tries = 3 )
reqs |
A list of httr2 requests, each prepared with |
source |
A friendly label for the service, used in the user-facing
message. For example |
max_active |
Maximum requests in flight at once. |
progress |
Passed to |
secret_query |
A named list of query-string credentials, applied to
every request in the batch at dispatch. See |
max_tries |
Total attempts per host group, including the first.
|
Each request must already have been through req_defaults(), the same as for
perform().
A list of envelopes, the same length and order as reqs. See
envelope().
httr2 applies req_throttle() and req_retry() across the whole list rather
than per request, so a throttled request to one host makes an unthrottled
request to another wait behind it. Requests are therefore grouped by host and
each group is dispatched separately, which keeps each host's throttle bucket
honest but means host groups run one after another.
Pass requests for one host. To query many different services at once, use process-level concurrency in the application instead.
httr2's own advice is never to perform in parallel without req_throttle(),
because it is otherwise very easy to flood a source with simultaneous
requests. req_defaults() takes a throttle argument and defaults its realm
to the request's host. Use it. Public biological data sources are typically
run on a research budget.
A host with an open breaker is never dispatched to, and every one of its
requests comes back skipped. But once a batch is in flight, a failure on the
first request cannot short-circuit the rest, because they have already been
sent. Transport failures within a batch are recorded, so they take effect on
the next call rather than the current one.
httr2 documents req_retry() as not firing under req_perform_parallel(),
so this function drives its own: a pass after the first re-sends only the
requests that came back rate_limited, timeout, or a 5xx. A settled
answer (ok, no_data, any other 4xx) is never retried. Before a retry
pass, the host's recorded pause is honored (see ratelimit_wait()), set
from that pass's Retry-After when a 429 or 503 sent one, or an
exponential backoff otherwise. See transport_stats() to see what a call
actually dispatched.
breaker_reset() reqs <- lapply( c("BRCA1", "TP53"), function(symbol) { req_defaults(httr2::request(paste0("https://mock.test/gene/", symbol))) } ) httr2::with_mocked_responses( function(req) { httr2::response( status_code = 200, headers = list(`content-type` = "application/json"), body = charToRaw('{"ok":true}') ) }, vapply(perform_many(reqs, "MyGene"), function(res) res$status, character(1)) )breaker_reset() reqs <- lapply( c("BRCA1", "TP53"), function(symbol) { req_defaults(httr2::request(paste0("https://mock.test/gene/", symbol))) } ) httr2::with_mocked_responses( function(req) { httr2::response( status_code = 200, headers = list(`content-type` = "application/json"), body = charToRaw('{"ok":true}') ) }, vapply(perform_many(reqs, "MyGene"), function(res) res$status, character(1)) )
The non-JSON counterpart to perform(), for a source that ships a bulk flat
file rather than a per-record JSON API. The body lands in data as a single
string, so the envelope shape is the same either way and a caller does not
branch on which wrapper it used.
perform_text(req, source = "API", secret_query = NULL)perform_text(req, source = "API", secret_query = NULL)
req |
An httr2 request, prepared with |
source |
A friendly label for the service, used in the user-facing
message. For example |
secret_query |
A named list of query-string credentials. Appended here
rather than by the caller, so |
The breaker rule is the same one: only a transport failure counts against the host.
An envelope whose data is a single string on success.
breaker_reset() req <- req_defaults(httr2::request("https://mock.test/genes.tsv")) httr2::with_mocked_responses( list(httr2::response( status_code = 200, body = charToRaw("gene\tscore\n") )), perform_text(req, "ClinGen")$data )breaker_reset() req <- req_defaults(httr2::request("https://mock.test/genes.tsv")) httr2::with_mocked_responses( list(httr2::response( status_code = 200, body = charToRaw("gene\tscore\n") )), perform_text(req, "ClinGen")$data )
Walks a key path and returns default if any level is missing or NULL,
rather than erroring. Parsed JSON is deeply nested and frequently missing
branches, so this is the difference between a client that reads cleanly and
one buried in if (!is.null(...)).
pluck_at(x, ..., default = NULL)pluck_at(x, ..., default = NULL)
x |
A list, usually a parsed JSON body. |
... |
Keys to follow, outermost first. |
default |
Returned when any level of the path is missing or |
The value at the end of the path, or default.
body <- list(data = list(gene = list(symbol = "BRCA1"))) pluck_at(body, "data", "gene", "symbol") pluck_at(body, "data", "variant", "id", default = NA_character_)body <- list(data = list(gene = list(symbol = "BRCA1"))) pluck_at(body, "data", "gene", "symbol") pluck_at(body, "data", "variant", "id", default = NA_character_)
For a JSON or GraphQL POST. Caches on the URL and the body together, so two different queries to the same endpoint do not collide.
post_json( url, body, source = "API", timeout = 20, max_tries = 3, headers = NULL, throttle = NULL, secret_query = NULL, ttl = NULL )post_json( url, body, source = "API", timeout = 20, max_tries = 3, headers = NULL, throttle = NULL, secret_query = NULL, ttl = NULL )
url |
The endpoint URL. |
body |
A list, serialized as the JSON request body. |
source |
A friendly label for the service. |
timeout |
Seconds before the request is abandoned. |
max_tries |
Total attempts, including the first. |
headers |
A named list of headers, all marked sensitive. |
throttle |
An optional throttle spec. See |
secret_query |
A named list of query-string credentials, for a service
that has no header form. Deliberately not part of the cache key, so it
suits a credential that raises a rate limit and not one that changes the
response. See |
ttl |
Seconds a cached success stays fresh. |
An envelope. See envelope().
cache_reset() breaker_reset() httr2::with_mocked_responses( list(httr2::response( status_code = 200, headers = list(`content-type` = "application/json"), body = charToRaw('{"data":{"gene":{"gene_id":"ENSG00000012048"}}}') )), post_json( "https://gnomad.broadinstitute.org/api", body = list(query = "{ gene(gene_symbol: \"BRCA1\") { gene_id } }"), source = "gnomAD" )$data$data$gene$gene_id )cache_reset() breaker_reset() httr2::with_mocked_responses( list(httr2::response( status_code = 200, headers = list(`content-type` = "application/json"), body = charToRaw('{"data":{"gene":{"gene_id":"ENSG00000012048"}}}') )), post_json( "https://gnomad.broadinstitute.org/api", body = list(query = "{ gene(gene_symbol: \"BRCA1\") { gene_id } }"), source = "gnomAD" )$data$data$gene$gene_id )
The batched counterpart to post_json(), for a JSON or GraphQL endpoint
answering many queries. Keyed on the URL and the body together, so two
different queries to the same endpoint do not collide.
post_json_many( url, bodies = list(), source = "API", timeout = 20, max_tries = 3, headers = NULL, throttle = NULL, max_active = 6, progress = FALSE, secret_query = NULL, ttl = NULL )post_json_many( url, bodies = list(), source = "API", timeout = 20, max_tries = 3, headers = NULL, throttle = NULL, max_active = 6, progress = FALSE, secret_query = NULL, ttl = NULL )
url |
The endpoint URL. |
bodies |
A list of request bodies, one per request. Each is serialized as JSON. |
source |
A friendly label for the service, used in the user-facing
message. For example |
timeout |
Seconds before a request is abandoned. |
max_tries |
Total attempts, including the first. A retry pass
re-sends only the entries that came back |
headers |
A named list of headers, all marked sensitive. |
throttle |
A throttle spec. See |
max_active |
Maximum requests in flight at once. |
progress |
Passed to |
secret_query |
A named list of query-string credentials, applied to
every request in the batch at dispatch. See |
ttl |
Seconds a cached success stays fresh, applied to every entry in
the batch. |
Send requests for one host, and pass a throttle. perform_many() explains
both.
A list of envelopes, the same length and order as bodies.
cache_reset() breaker_reset() httr2::with_mocked_responses( function(req) { httr2::response( status_code = 200, headers = list(`content-type` = "application/json"), body = charToRaw('{"data":{}}') ) }, length(post_json_many( "https://gnomad.broadinstitute.org/api", bodies = list(list(query = "{ a }"), list(query = "{ b }")), source = "gnomAD" )) )cache_reset() breaker_reset() httr2::with_mocked_responses( function(req) { httr2::response( status_code = 200, headers = list(`content-type` = "application/json"), body = charToRaw('{"data":{}}') ) }, length(post_json_many( "https://gnomad.broadinstitute.org/api", bodies = list(list(query = "{ a }"), list(query = "{ b }")), source = "gnomAD" )) )
Sets how long the next dispatch to host should wait: from a 429 or
503's Retry-After when one was sent, or a default pause (5 seconds,
BIOHTTP_RATELIMIT_DEFAULT_PAUSE to override) otherwise. A call only
extends an existing pause, never shortens one, so of several retryable
responses recorded from one batch, the longest asked-for wait wins.
ratelimit_record(host, retry_after = NULL, now = as.numeric(Sys.time()))ratelimit_record(host, retry_after = NULL, now = as.numeric(Sys.time()))
host |
A hostname, as returned by parsing a request URL. |
retry_after |
Seconds to wait, usually from
|
now |
The current time in seconds since the epoch. Exposed so a test can drive the clock rather than sleep. |
The host's new pause_until, invisibly.
ratelimit_reset() ratelimit_record("rest.ensembl.org", retry_after = 5) ratelimit_reset()ratelimit_reset() ratelimit_record("rest.ensembl.org", retry_after = 5) ratelimit_reset()
For tests, and for a manual "try again now" once a source's limiter is known to have reset.
ratelimit_reset()ratelimit_reset()
NULL, invisibly.
ratelimit_reset()ratelimit_reset()
A no-op when the host has no pause recorded, or the recorded one has
already elapsed. sleep is injectable so a test can assert the duration
asked for without actually waiting for it.
ratelimit_wait(host, now = as.numeric(Sys.time()), sleep = Sys.sleep)ratelimit_wait(host, now = as.numeric(Sys.time()), sleep = Sys.sleep)
host |
A hostname. |
now |
The current time in seconds since the epoch. |
sleep |
The sleep function to call with the remaining seconds.
Defaults to |
The number of seconds waited (0 when there was nothing to wait
for), invisibly.
ratelimit_reset() ratelimit_wait("rest.ensembl.org")ratelimit_reset() ratelimit_wait("rest.ensembl.org")
Replaces each secret wherever it appears, in both the form it was passed and the percent-encoded form a URL carries. Used on the messages built from a transport failure, because a curl error normally carries the URL that failed and that URL may hold a query-string credential.
redact_secrets(text, secrets = NULL)redact_secrets(text, secrets = NULL)
text |
A single string. |
secrets |
A named list of secret values, as passed to |
Matching on the value rather than on the parameter name is deliberate: the same secret can reach a message through a URL, a header dump, or a proxy error, and only the value is common to all three.
Both forms have to be matched because a secret never reaches a URL in the
form it was passed. httr2::req_url_query() percent-encodes it, so a key
holding +, /, or =, which is an ordinary shape for a base64 key,
arrives as %2B, %2F, and %3D. Matching only the raw value would let
exactly those keys through into a logged detail.
text, with every secret value replaced by <redacted>.
redact_secrets( "Could not resolve host: example.org/?api_key=abc123", list(api_key = "abc123") ) # The encoded form is caught too, which is the one a URL actually carries. redact_secrets( "Could not resolve host: example.org/?api_key=ab%2Bcd", list(api_key = "ab+cd") )redact_secrets( "Could not resolve host: example.org/?api_key=abc123", list(api_key = "abc123") ) # The encoded form is caught too, which is the one a URL actually carries. redact_secrets( "Could not resolve host: example.org/?api_key=ab%2Bcd", list(api_key = "ab+cd") )
Timeout, bounded retry on transient failures, an attributable user agent, and optionally a throttle and headers.
req_defaults( req, timeout = 20, max_tries = 3, headers = NULL, throttle = NULL, user_agent = default_user_agent() )req_defaults( req, timeout = 20, max_tries = 3, headers = NULL, throttle = NULL, user_agent = default_user_agent() )
req |
An httr2 request. |
timeout |
Seconds before the request is abandoned. |
max_tries |
Total attempts, including the first. |
headers |
A named list of headers. All of them are marked sensitive. |
throttle |
An optional list with |
user_agent |
The User-Agent string. See |
req_error() is disarmed here on purpose. httr2 would otherwise raise on a
non-2xx, and this package normalizes every outcome into an envelope instead,
so perform() needs the response object rather than a condition.
Headers are attached with .redact set to their names, so a token never
prints in an inspected request, a log line, or an error message. Header auth
also keeps the secret out of the URL, where it would end up in access logs.
The request, with the options applied.
req_defaults(httr2::request("https://example.org"), timeout = 5) # A key-gated source. The token is redacted from anything printable. req_defaults( httr2::request("https://example.org"), headers = list(Authorization = "Bearer secret-token") )req_defaults(httr2::request("https://example.org"), timeout = 5) # A key-gated source. The token is redacted from anything printable. req_defaults( httr2::request("https://example.org"), headers = list(Authorization = "Bearer secret-token") )
Shorthands for envelope() that fill in the user-facing error sentence for
their status. error is what a user reads; detail is what a log records.
status_ok(data, source = "API", http = 200L) status_no_data(source = "API", http = NA_integer_, detail = NULL) status_stale(data, source = "API", detail = NULL) status_rate_limited( source = "API", http = 429L, detail = NULL, retry_after = NA_real_ ) status_timeout(source = "API", detail = NULL) status_skipped(source = "API", detail = NULL) status_error(source = "API", http = NA_integer_, error = NULL, detail = NULL)status_ok(data, source = "API", http = 200L) status_no_data(source = "API", http = NA_integer_, detail = NULL) status_stale(data, source = "API", detail = NULL) status_rate_limited( source = "API", http = 429L, detail = NULL, retry_after = NA_real_ ) status_timeout(source = "API", detail = NULL) status_skipped(source = "API", detail = NULL) status_error(source = "API", http = NA_integer_, error = NULL, detail = NULL)
data |
The parsed body on success, |
source |
A friendly label for the service, used in the user-facing
message. For example |
http |
The HTTP status code, or |
detail |
The technical cause, for a log. Never shown to a user. |
retry_after |
Seconds a |
error |
One sentence fit to show a user. Never carries technical detail. |
The sentence comes from status_message(), so setting
biohttp.status_message changes what these produce too.
An envelope list, as described in envelope().
status_ok(data = list(n = 1), source = "MyGene") status_no_data(source = "gnomAD", http = 404L) status_skipped(source = "gnomAD", detail = "gnomad.broadinstitute.org open")status_ok(data = list(n = 1), source = "MyGene") status_no_data(source = "gnomAD", http = 404L) status_skipped(source = "gnomAD", detail = "gnomad.broadinstitute.org open")
Ordered from best outcome to worst.
STATUS_LEVELSSTATUS_LEVELS
A character vector of length seven.
okThe call succeeded and data holds the parsed result.
no_dataThe source was reached and has nothing for this query, for example a 404 or an empty body. This is an answer, not a fault.
staleA cached value served past its freshness window.
rate_limitedThe source asked the caller to slow down, a 429.
timeoutThe request did not return in time.
skippedNot attempted, because the host's breaker is open or a caller-side budget was hit.
errorAnything else: a 5xx, a malformed body, a transport failure.
Every error field in the package is produced here, so the seven statuses
cannot drift apart and an application can replace all of them at once.
status_message( source = "API", status = "error", http = NA_integer_, condition = NULL )status_message( source = "API", status = "error", http = NA_integer_, condition = NULL )
source |
A friendly label for the service, for example |
status |
One of STATUS_LEVELS. |
http |
An HTTP status code, or |
condition |
A caught condition, used when no response arrived. |
A single string, or NULL for a status that carries no message.
Set biohttp.status_message to a function of source, status, http and
condition. It is called instead of the built-in, and it is the way to keep
an app's own voice, or to localize, while adopting the transport:
options(biohttp.status_message = function(source, status, http, condition) {
if (identical(status, "timeout")) {
paste0(source, " took too long. Please try again shortly.")
} else {
NULL # fall through to the built-in for everything else
}
})
Returning anything other than a single non-empty string falls back to the built-in, so covering one case and leaving the rest is expected rather than an error.
An override cannot break the return-a-value contract. It runs inside a
tryCatch(), and a function that raises is treated as though it returned
nothing. This matters because the override runs on the failure path, which is
precisely where the package promises never to raise.
status_message("gnomAD", "no_data") status_message("gnomAD", "error", http = 503L) old <- options( biohttp.status_message = function(source, status, http, condition) { paste0(source, " says: ", status) } ) status_message("gnomAD", "timeout") options(old)status_message("gnomAD", "no_data") status_message("gnomAD", "error", http = 503L) old <- options( biohttp.status_message = function(source, status, http, condition) { paste0(source, " says: ", status) } ) status_message("gnomAD", "timeout") options(old)
How many requests the batched path (see perform_many() and its
cached counterparts) actually sent, how many of those were a retry pass
rather than the first attempt, and how many came back rate limited,
since the process started or the last transport_stats_reset().
transport_stats()transport_stats()
A data frame with one row per host: host, dispatched,
retried, rate_limited. Zero rows when nothing has dispatched yet.
transport_stats_reset() transport_stats()transport_stats_reset() transport_stats()
Clear the request counts
transport_stats_reset()transport_stats_reset()
NULL, invisibly.
transport_stats_reset()transport_stats_reset()
Public sources ask callers to identify themselves. A caller identity plus a contact route let a source operator reach you before they rate-limit you.
user_agent( identity = env_chr("BIOHTTP_CALLER_IDENTITY", "biohttp"), version = as.character(getNamespaceVersion("biohttp")), url = env_chr("BIOHTTP_CONTACT_URL", ""), email = env_chr("BIOHTTP_CONTACT_EMAIL", "") )user_agent( identity = env_chr("BIOHTTP_CALLER_IDENTITY", "biohttp"), version = as.character(getNamespaceVersion("biohttp")), url = env_chr("BIOHTTP_CONTACT_URL", ""), email = env_chr("BIOHTTP_CONTACT_EMAIL", "") )
identity |
The calling application's name. Defaults to
|
version |
The calling application's version. |
url |
A URL an operator can look up, usually the app's repository.
Defaults to |
email |
A contact address. Defaults to |
The package deliberately does not ship an identity of its own. A consumer
passes its own, or sets BIOHTTP_CALLER_IDENTITY, BIOHTTP_CONTACT_URL, and
BIOHTTP_CONTACT_EMAIL so a hosted deployment can set them without touching
code.
A single string.
user_agent("my-shiny-app", "1.2.0", email = "[email protected]") user_agent("my-client-package", "0.4.0")user_agent("my-shiny-app", "1.2.0", email = "[email protected]") user_agent("my-client-package", "0.4.0")