Ankusa.HttpClient (ankusa v0.7.0)

Copy Markdown View Source

The one place the outbound adapters issue HTTP requests: the object stores (Ankusa.BlobStore.S3, GCS, Azure, OCI), Ankusa.Sink.Http, Ankusa.Sink.SQS, and Ankusa.Sink.GooglePubSub.

Those adapters share more than a client. Every request they send is a function of its own bytes — a signed S3 URL, a signed SendMessage, a forwarded hook body — which makes four options part of each adapter's contract rather than a preference:

  • retry: false — retries belong to the framework's own loops (Ankusa.RetryPolicy for the sinks, Ankusa.BlobStore.Retry around one object-store request, re-signed per attempt). Req's default retry would add hidden latency inside those, and resend a signature as it ages.
  • redirect: false — a followed redirect re-issues a POST as a GET, so a 302 from a sink endpoint would be reported as a successful delivery. A redirect on a signed request is never more useful: the signature covers the host and path it was made for.
  • http_errors: :return — a non-2xx status is a value each adapter maps ({:error, :not_found}), not an exception.
  • decode_body: false — these bodies are segments, claims and hook payloads, not JSON; the one JSON reply (SQS's) is checked against the bytes sent, so its adapter decodes it itself.

:req_options is an allowlist

Callers reach this module through each adapter's :req_options, and the accepted keys are [:finch, :connect_options, :pool_timeout, :plug]: transport tuning that cannot change the request. Anything else raises, because the keys that can change it — :params, :base_url, :auth, :headers — would alter a URL that has already been signed, or replace the headers carrying a credential. Silently ignoring them would be a worse failure than refusing them: the caller would believe a proxy or a pool was in use.

TLS is verified, always

An https request verifies the peer's certificate chain and hostname (verify: :verify_peer) and negotiates TLS 1.2 or 1.3 only. The trust store is the operating system's, with castore's bundle behind it where OTP cannot load that; :cacertfile in opts names a PEM bundle instead (a private CA). Turning verification off is not a :req_options setting: a transport_opts: [verify: :verify_none] raises ArgumentError from validate!/2, and the one way past it is allow_insecure_tls: true in opts, which Ankusa.Sink.Http exposes per sink for a receiver that cannot present a certificate.

A named pool owns its transport

finch: [name: MyFinch] sends the request through a pool the caller started, and that pool's conn_opts are the connection options (timeout, TLS, proxy): Req refuses :finch beside :connect_options, so the request carries none. Asking for TLS settings per request on top of a named pool (:cacertfile, allow_insecure_tls: true, :connect_options) raises ArgumentError from validate!/3, rather than being ignored. Ankusa.BlobStore.Pool is such a pool.

Every other request goes through an owned, bounded pool

A request with no :finch and no :plug in its options is sent through Ankusa.HttpClient.Pools: a pool per origin and connection profile, sized and idle-swept from the instance's egress.pools config, never Req's default of an unbounded number of pools that are never closed. The connection options the request would carry (connect timeout, TLS, the :connect_options in req_options) become the pool's, so every connection it opens uses them. opts takes the pool's context (taken out before the request is built): :instance (whose egress.pools apply; none uses the defaults), :tenant (whose ceiling the pool counts against), :source (what close_source/3 closes pools for) and :pool (a per-sink override, see Ankusa.HttpClient.Pools.validate_override!/1). A pool ceiling that cannot be made room for is {:error, {:unavailable, :pool_limit}}.

Summary

Functions

The host, or host:port when the port is not the scheme's default, of url — a host header, a description's :host.

Issue one request, returning the status and raw body.

request/6, plus the response headers (%{lowercase_name => [value]}), for callers that read one such as retry-after.

Raise ArgumentError unless req_options holds only transport keys (:finch, :connect_options, :pool_timeout, :plug) and does not turn off TLS verification (connect_options: [transport_opts: [verify: :verify_none]]) without allow_insecure_tls?, nor asks a named :finch pool (see "A named pool owns its transport") for per-request connection options: :connect_options, a cacertfile or allow_insecure_tls?: true.

Functions

authority(url)

@spec authority(String.t()) :: String.t()

The host, or host:port when the port is not the scheme's default, of url — a host header, a description's :host.

request(method, url, headers, body, timeout_ms, opts \\ [])

@spec request(
  atom(),
  String.t(),
  [{String.t(), String.t()}],
  iodata() | nil,
  timeout(),
  keyword()
) ::
  {:ok, non_neg_integer(), term()} | {:error, term()}

Issue one request, returning the status and raw body.

timeout_ms is applied to both the connection and the response. A :connect_options in req_options is merged over it, so supplying a proxy never drops the timeout.

:max_response_bytes in opts (not a transport option; it is taken out before the request is built) stops reading the response once its body passes that many bytes: the body is then {:truncated, bytes_read} and the connection is closed rather than drained. Without it the whole body is read, which the blob stores need.

:cacertfile and :allow_insecure_tls in opts are likewise taken out before the request is built; see "TLS is verified, always".

request_with_headers(method, url, headers, body, timeout_ms, opts \\ [])

@spec request_with_headers(
  atom(),
  String.t(),
  [{String.t(), String.t()}],
  iodata() | nil,
  timeout(),
  keyword()
) ::
  {:ok, non_neg_integer(), %{required(String.t()) => [String.t()]}, term()}
  | {:error, term()}

request/6, plus the response headers (%{lowercase_name => [value]}), for callers that read one such as retry-after.

validate!(req_options, allow_insecure_tls? \\ false, cacertfile \\ nil)

@spec validate!(keyword(), boolean(), String.t() | nil) :: :ok

Raise ArgumentError unless req_options holds only transport keys (:finch, :connect_options, :pool_timeout, :plug) and does not turn off TLS verification (connect_options: [transport_opts: [verify: :verify_none]]) without allow_insecure_tls?, nor asks a named :finch pool (see "A named pool owns its transport") for per-request connection options: :connect_options, a cacertfile or allow_insecure_tls?: true.

request/6 calls it on every request, and Ankusa.Sink.Http.validate_opts!/1 calls it when a config is validated, so a bad :req_options on an HTTP sink fails at boot, not at its first delivery.