summaryrefslogtreecommitdiffstats
path: root/R/client.R
blob: cdd72f0974ceb5ac4d67ada54acb43a2179df24b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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
}