summaryrefslogtreecommitdiffstats
path: root/R/client.R
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:42:37 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:42:37 -0300
commit11d49bc25eb1643f1b742f9527212d8781f2e0de (patch)
treebae1cb090cbf6c9f82ab5abbaf1194206f92f539 /R/client.R
downloadr-11d49bc25eb1643f1b742f9527212d8781f2e0de.tar.gz
Initial importv0.1.0
Diffstat (limited to 'R/client.R')
-rw-r--r--R/client.R122
1 files changed, 122 insertions, 0 deletions
diff --git a/R/client.R b/R/client.R
new file mode 100644
index 0000000..cdd72f0
--- /dev/null
+++ b/R/client.R
@@ -0,0 +1,122 @@
+DEFAULT_BASE_URL <- "https://cep.is"
+
+MAX_BATCH <- 500L
+
+RETRIABLE_STATUS <- c(429L, 500L, 502L, 503L, 504L)
+
+#' Build a client for the cep.is API
+#'
+#' @description
+#' Holds the configuration and nothing else: no connection, no state between
+#' calls. Build one and reuse it, or skip it entirely — [cep_lookup()],
+#' [cep_lookup_many()] and [cep_health()] fall back to a shared default
+#' client built with these defaults.
+#'
+#' @param base_url Where the API lives. Point it at a local instance (the API
+#' repo brings one up on `http://localhost:8080` with `just up`) or at a
+#' test server. It is the only parameter that usually changes between
+#' environments. Trailing slashes are stripped, so it cannot produce
+#' doubled separators that some proxies normalise and others 404.
+#' @param timeout Seconds allowed for **one attempt**, connection and
+#' transfer together. It is not a cap on the whole call, since a call may
+#' make several attempts; that cap is `total_timeout`.
+#' @param max_attempts How many attempts in total, counting the first. `1`
+#' disables retrying. The default of `3` covers the common case (one
+#' transient failure) without turning a real outage into a long wait.
+#' @param backoff_base Base of the exponential wait, in seconds. The actual
+#' wait is drawn from `[0, base * 2^(n - 1)]` — *full jitter*. The
+#' randomness is not decoration: without it, N clients that fail together
+#' come back together and reproduce the spike that took the service down.
+#' @param backoff_max Cap on each individual wait, in seconds. It applies to
+#' a server-sent `Retry-After` too, or a `Retry-After: 3600` from a
+#' misconfigured proxy would park the session for an hour.
+#' @param total_timeout Cap on the whole call, attempts and waits included.
+#' `NULL` leaves `max_attempts` as the only bound. Set it in code serving a
+#' web request: without it the worst case is `max_attempts * timeout` plus
+#' the sum of the backoffs.
+#' @param user_agent Overrides the `User-Agent`. The default identifies the
+#' package and its version, which is what lets the server diagnose an old
+#' client misbehaving.
+#'
+#' @return An object of class `cepis_client`, to pass as the `client`
+#' argument of [cep_lookup()], [cep_lookup_many()] and [cep_health()].
+#'
+#' @examples
+#' client <- cepis_client(base_url = "http://localhost:8080", timeout = 5)
+#' client$max_attempts
+#'
+#' @export
+cepis_client <- function(base_url = DEFAULT_BASE_URL,
+ timeout = 10,
+ max_attempts = 3L,
+ backoff_base = 0.5,
+ backoff_max = 8,
+ total_timeout = NULL,
+ user_agent = NULL) {
+ if (!is.character(base_url) || length(base_url) != 1L || is.na(base_url)) {
+ stop("base_url must be a length-one character vector")
+ }
+ if (!is.numeric(timeout) || length(timeout) != 1L || is.na(timeout) ||
+ timeout <= 0) {
+ stop("timeout must be a positive number of seconds")
+ }
+ if (!is.numeric(max_attempts) || length(max_attempts) != 1L ||
+ is.na(max_attempts) || max_attempts < 1) {
+ stop("max_attempts must be >= 1")
+ }
+ if (!is.numeric(backoff_base) || length(backoff_base) != 1L ||
+ is.na(backoff_base) || backoff_base < 0) {
+ stop("backoff_base must be >= 0")
+ }
+ if (!is.numeric(backoff_max) || length(backoff_max) != 1L ||
+ is.na(backoff_max) || backoff_max < 0) {
+ stop("backoff_max must be >= 0")
+ }
+ if (!is.null(total_timeout) &&
+ (!is.numeric(total_timeout) || length(total_timeout) != 1L ||
+ is.na(total_timeout) || total_timeout <= 0)) {
+ stop("total_timeout must be NULL or a positive number of seconds")
+ }
+ if (is.null(user_agent)) {
+ user_agent <- paste0(
+ "cepis-r/", as.character(utils::packageVersion("cepis")),
+ " (+https://cep.is)"
+ )
+ }
+
+ structure(
+ list(
+ base_url = sub("/+$", "", base_url),
+ timeout = as.numeric(timeout),
+ max_attempts = as.integer(max_attempts),
+ backoff_base = as.numeric(backoff_base),
+ backoff_max = as.numeric(backoff_max),
+ total_timeout = total_timeout,
+ user_agent = user_agent
+ ),
+ class = "cepis_client"
+ )
+}
+
+#' @export
+print.cepis_client <- function(x, ...) {
+ cat("<cepis_client> ", x$base_url, "\n", sep = "")
+ cat(" timeout: ", x$timeout, "s per attempt, ", x$max_attempts,
+ " attempt(s)\n", sep = "")
+ cat(" backoff: ", x$backoff_base, "s base, ", x$backoff_max, "s cap\n",
+ sep = "")
+ cat(" total timeout: ",
+ if (is.null(x$total_timeout)) "none" else paste0(x$total_timeout, "s"),
+ "\n", sep = "")
+ cat(" user agent: ", x$user_agent, "\n", sep = "")
+ invisible(x)
+}
+
+cepis_state <- new.env(parent = emptyenv())
+
+default_client <- function() {
+ if (is.null(cepis_state$client)) {
+ cepis_state$client <- cepis_client()
+ }
+ cepis_state$client
+}