# `Ankusa.HttpClient`
[🔗](https://github.com/jamescarr/ankusa/blob/ankusa-v0.7.0/packages/ankusa/lib/ankusa/http_client.ex#L1)

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}}`.

# `authority`

```elixir
@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`

```elixir
@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`

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

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

# `validate!`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
