biohttp is the transport layer under a family of bio-database clients. It knows how to make an HTTP call and report what happened. It does not know what a gene is, and it never will.
This is what you need to know to write a client on top of it.
The one rule
A call returns a value. It never raises.
That is the whole contract. You do not write tryCatch()
around a biohttp call, because there is nothing to catch. A DNS failure,
a 503, a 200 carrying an HTML maintenance page: all three come back as a
value you branch on.
res <- httr2::with_mocked_responses(
list(httr2::response(
status_code = 200,
headers = list(`content-type` = "application/json"),
body = charToRaw('{"symbol":"BRCA1","entrezgene":672}')
)),
get_json("https://mygene.info/v3", path = "gene/672", source = "MyGene")
)
res$ok
#> [1] TRUE
res$status
#> [1] "ok"
res$data$symbol
#> [1] "BRCA1"What comes back
Every call returns the same eight fields.
| Field | What it holds |
|---|---|
ok |
TRUE only when status is
"ok". Derived, so the two cannot disagree. |
status |
One of the seven levels below. This is what you branch on. |
http |
The HTTP code, or NA_integer_ when no response
arrived. |
data |
The parsed body on success. |
source |
The label you passed, used in the message. |
error |
One sentence fit to show a user. Never technical. |
detail |
The technical cause, for a log. Never shown to a user. |
ts |
When the envelope was built. |
The error and detail split is deliberate.
Render error in the interface, write detail to
the log, and a stack trace never reaches a user while the log never
loses one.
The seven statuses
STATUS_LEVELS
#> [1] "ok" "no_data" "stale" "rate_limited" "timeout"
#> [6] "skipped" "error"They are ordered best to worst. The three that people miss:
-
no_datais an answer, not a fault. The source was reached and has nothing for this query. A 404 from a lookup API usually means “no such record”, which is information. -
skippedmeans nothing was sent, because the host’s circuit breaker was open. Distinguishing this fromerroris why the enum exists: a skipped call is not evidence about the query, only about the host. -
stalecarries real data that is past its freshness window.okisFALSEso it never gets cached, but the body is still there.
Branching
Handle the cases you can act on and let the rest fall through:
render <- function(res) {
switch(res$status,
ok = paste("got", length(res$data), "fields"),
no_data = "nothing found for that query",
skipped = "source paused, try again shortly",
rate_limited = "slow down",
res$error
)
}
render(status_ok(data = list(a = 1, b = 2), source = "MyGene"))
#> [1] "got 2 fields"
render(status_no_data(source = "MyGene"))
#> [1] "nothing found for that query"
render(status_error(source = "MyGene", http = 503L))
#> [1] "MyGene is temporarily unavailable. Please try again."If you genuinely do not care why a call failed,
body_or_null() collapses it:
body_or_null(status_ok(data = list(n = 1)))
#> $n
#> [1] 1
body_or_null(status_error(source = "MyGene"))
#> NULLReach for that when the failure is not actionable, not to avoid reading the envelope.
Building a client
A client is a thin function per endpoint. Assemble, call, parse. Notice there is no error handling in it, because there is nothing to handle.
mygene_query <- function(symbol, species = "human") {
res <- get_json(
"https://mygene.info/v3",
path = "query",
query = list(q = symbol, species = species),
source = "MyGene"
)
if (!res$ok) {
return(res)
}
# Reshape the body, and return an envelope so the caller's branching still
# works. Never return a bare value on success and NULL on failure: that is the
# shape this package exists to replace.
status_ok(
data = pluck_at(res$data, "hits", default = list()),
source = "MyGene",
http = res$http
)
}Two things to copy from that:
-
Pass
source. It is what makes theerrorsentence say “MyGene is temporarily unavailable” rather than “API is temporarily unavailable”. -
Return an envelope, not a bare value. A client that
returns
NULLon failure throws away the reason, and its caller has to invent a convention.
Credentials
Pass a token as a header, never in the URL. It is redacted from anything printable, and it stays out of access logs:
req <- req_defaults(
httr2::request("https://example.org/v1"),
headers = list(Authorization = "Bearer a-real-token")
)
# The value is not in the printed request.
any(grepl("a-real-token", capture.output(print(req)), fixed = TRUE))
#> [1] FALSEThe wrappers take headers too, and fold them into the
cache key, so two callers with different tokens never share a cached
response.
Asking many questions at once
One question per call is the wrong unit for most clients. A gene list, a variant list, a panel: the shape is nearly always many questions to one source, and asking them one at a time spends the round trip over and over on a call that is already almost entirely waiting.
get_json_many() takes a list of queries instead of
one:
res <- get_json_many(
"https://mygene.info/v3",
path = "query",
queries = lapply(c("BRCA1", "TP53", "EGFR"), function(g) list(q = g)),
source = "MyGene",
throttle = list(capacity = 10, fill_time_s = 60)
)
vapply(res, function(r) r$status, character(1))
#> [1] "ok" "ok" "ok"Results come back in the order you asked, so you can
zip them onto your inputs by position. post_json_many() is
the same idea for a JSON or GraphQL endpoint.
The part that pays is the cache. Entries already held are served
without a request, and only the rest go out. Ask for fifty genes when
forty are warm and ten requests leave the process. The keys are built
exactly the way get_json() builds its own, so a batch
reuses what a single call warmed and the other way around.
One source, not a mix
These are for many questions to one source. httr2
applies req_throttle() and req_retry() across
the whole list rather than per request, which as its own documentation
puts it makes req_perform_parallel() “most suitable for
performing many parallel requests to the same host, rather than a mix of
different hosts”. A throttled request to one host would make an
unthrottled request to another wait behind it.
Requests are grouped by host so that stays true even if you pass a mixed list, and each host’s throttle bucket stays honest. Host groups run one after another.
If what you want is a dozen different services answered at once, that is process-level concurrency with a worker pool, and it belongs in your application. This package does not manage a daemon pool and is not trying to.
The circuit breaker
After a few consecutive transport failures, a host is skipped for a cooldown rather than waited on. It heals itself; there is nothing to reset.
The rule that matters:
Only a transport failure counts against a host. Any HTTP response at all, including a 500 and including a 200 whose body will not parse, proves the host is reachable and clears the count.
This is not a detail. A source that starts returning HTML error pages with a 200 is broken, but it is up, and taking it out of rotation would turn a partial outage into a total one. This is the rule a hand-rolled breaker most often gets wrong, and a large part of why the package exists.
Caching
Successes are cached. Failures never are.
A cache that stores a failure poisons itself for the life of the process: every later lookup serves the stored failure instead of retrying. Storing only successes means a transient outage resolves itself the moment the source comes back.
Memory only by default. The disk tier is opt-in, because a library should not start writing to somebody’s disk because they installed it:
| Variable | Default | What it does |
|---|---|---|
BIOHTTP_CACHE_TTL |
1800 | Memory tier max age, seconds |
BIOHTTP_CACHE_MAX_SIZE |
256 MB | Memory tier ceiling, bytes |
BIOHTTP_CACHE_MAX_N |
unlimited | Memory tier ceiling, number of entries |
BIOHTTP_CACHE_DISK |
off | Set to true to add the disk tier |
BIOHTTP_CACHE_DIR |
data/cache |
Where the disk tier lives |
BIOHTTP_CACHE_DISK_TTL |
7 days | Disk tier max age, seconds |
BIOHTTP_CACHE_SALT |
empty | Set per deployment |
The two ceilings bound different things, and a long-running process
can blow through one while sitting well inside the other.
BIOHTTP_CACHE_MAX_SIZE bounds bytes, so a server answering
thousands of small responses stays under it while holding far more
entries than you meant to. Set BIOHTTP_CACHE_MAX_N if the
count is what you care about. Eviction is least-recently-used in both
cases.
Set BIOHTTP_CACHE_SALT if you enable the disk tier.
Without it a shared cache directory can collide across app versions, and
it leaks which queries were run to anyone able to probe it.
If the disk directory turns out to be unwritable, the cache degrades to memory-only rather than failing, which is what lets the same code run in a container with no writable volume.
GraphQL
A GraphQL error arrives inside an HTTP 200, as a top-level
errors array, so a 2xx is not enough to call the request
successful. graphql_error() folds both checks into one:
res <- status_ok(data = list(errors = list(list(message = "bad field"))))
bad <- graphql_error(res, "gnomAD")
bad$status
#> [1] "error"It returns NULL when the call genuinely succeeded, so
the idiom is:
res <- post_json(url, body = list(query = q), source = "gnomAD")
bad <- graphql_error(res, "gnomAD")
if (!is.null(bad)) {
return(bad)
}Migrating an existing app
If your app already returns the older four-field shape,
as_legacy_envelope() translates for you so a migration
lands as a reviewable diff:
old <- as_legacy_envelope(status_ok(data = list(n = 1), source = "MyGene"))
#> Warning: as_legacy_envelope() is deprecated and will be removed in biohttp
#> 0.3.0. Branch on res$status instead; see vignette("biohttp").
str(old)
#> List of 5
#> $ ok : logi TRUE
#> $ status: int 200
#> $ data :List of 1
#> ..$ n: num 1
#> $ error : NULL
#> $ detail: NULLIt is deprecated on arrival and scheduled for removal in 0.3.0. The
old shape cannot express skipped, and it collapses
error and detail into one field. Use it to get
the migration merged, then delete the call sites.
Testing your client
Test offline. biohttp’s own suite never touches a real host, and yours should not either.
For everything except retry,
httr2::with_mocked_responses() is enough:
httr2::with_mocked_responses(
list(httr2::response(status_code = 503)),
expect_identical(mygene_query("BRCA1")$status, "error")
)Retry is the exception. Mocking replaces the transport below
the retry loop, so a mocked 503 arrives exactly once no matter what
max_tries says. If you need to test retry, run a real local
server with webfakes, the way
tests/testthat/test-retry.R does.
Worth asserting in your own suite, because these are the things that quietly regress:
- A failure is never cached.
- A 5xx does not trip the breaker.
- A token never appears in a printed request.