# Performance & Capacity

> **Status:** v0.1 — first pass, finalized 2026-05-15
> (review-pass verified all file:line citations against the
> live source). Last updated 2026-05-15.
> A federation-sibling-scoped performance audit of the async
> HTTP client wrapper. Most performance is **httpx's** —
> mgf-http adds a thin wrapper. The PC items measure the
> wrapper-side overhead + the load-bearing one-shot ops
> (User-Agent compute, retry backoff math).
>
> **Scope:** mgf-http's OWN performance footprint. NOT a
> re-audit of httpx. NOT consumer-side throughput against
> any specific provider.
>
> **Audience:** the maintainer + future AI agents.
> **Cross-references:**
> [`RELIABILITY_AND_ACCURACY.md`](RELIABILITY_AND_ACCURACY.md),
> [`SECURITY_HARDENING.md`](SECURITY_HARDENING.md),
> [`mgf-common/docs/inprogress/PERFORMANCE_AND_CAPACITY.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/inprogress/PERFORMANCE_AND_CAPACITY.md)
> (cornerstone).

The library is a thin wrapper over httpx. Performance work
here is mostly **measuring the wrapper tax**, plus pinning
the budgets where mgf-http does real work (retry budget,
log emission per call, redaction set lookup).

---

## §1 You are here

mgf-http ships three public names; `AsyncHttpClient.request`
is the load-bearing call (called per HTTP request); the
retry policy is consulted twice per request (status check +
backoff calc). Header redaction runs once per logged request.

What this tracker exposes is the **wrapper tax** + the
**capacity story** (sustained throughput, connection pool
behavior under load).

**Counts (2026-05-17):**

| Priority | Open | Closed | Total |
|---|---|---|---|
| P0 — pre-v1.0 blockers | 0 | 0 | 0 |
| P1 — should land before/with v1.0 | 0 | 3 | 3 |
| P2 — post-v1.0 hardening | 0 | 2 | 2 |
| P2 — deferred to v1.1+ | 1 | 0 | 1 |
| **Total** | **1** | **5** | **6** |

**Do first:** P2 sweep complete (2026-05-17). PC-04 + PC-05
closed; PC-06 deferred to v1.1+ (PC-02 already pins resource
discipline + latency stability over a sustained window; PC-06's
60s time-paced shape is incremental, not v1.0-blocking).

---

## §2 Priority table

| ID | Theme | One-liner | Effort | Status |
|---|---|---|---|---|
| [**PC-01**](#pc-01-per-call-wrapper-overhead-pin) | Latency | `AsyncHttpClient.request` adds work over `httpx.AsyncClient.request`: header merge, cookie pack, retry-policy lookup, OTel span, log emission. Quantify the wrapper tax in µs per call. | S | ✅ closed |
| [**PC-02**](#pc-02-connection-pool-capacity-profile) | Capacity | `AsyncHttpClient` holds one `httpx.AsyncClient` with its connection pool. For a consumer probing many providers, the pool grows. httpx defaults: 100 connections. Document + measure the growth pattern under sustained load. | S | ✅ closed |
| [**PC-03**](#pc-03-user-agent-computation-cache) | Allocation | `_default_user_agent` is called inside `AsyncHttpClient.__init__` — every client construction reads `importlib.metadata.version`, calls `mgf.common.app_name()` + `app_version()`. Per-construction cost ~50-200 µs. For test runs constructing many clients, the cost compounds. | XS | ✅ closed |
| [**PC-04**](#pc-04-header-redaction-set-lookup) | Latency | `_redact_headers` iterates the request-headers dict, calls `.lower()` on each key + checks against `_SECRET_HEADER_KEYS` frozenset. For requests with 5-20 headers, ~5-20 lower() + 5-20 set-membership checks. Sub-µs total but worth pinning. | XS | ✅ closed |
| [**PC-05**](#pc-05-retry-policy-iteration-cost) | Latency (retry path) | `_send_with_retries` consults `retry_policy.is_method_retryable` + `is_status_retryable` per attempt. The policy is a frozen dataclass with frozenset memberships — sub-µs. Pin to catch regressions where the policy lookup somehow grows. | XS | ✅ closed |
| [**PC-06**](#pc-06-sustained-throughput-1000-req-sec) | Capacity | No sustained-throughput test exists. A consumer probing a provider at 100 req/s for 60 s should have NO accumulation: stable memory, stable latency, no GC pause cliffs. Pin via a `pytest.mark.slow` test (not perf — capacity). | M | ⏳ v1.1+ — PC-02 already pins resource discipline |

Effort scale: XS (≤ 15 min); S (≤ 1 h); M (≤ 1 session).

---

## §3 Invariants we hold

The list of "closed elsewhere; do not re-open":

- **PC-INV-1** — httpx is **C-extension-backed** for HTTP/1.1
  + HTTP/2 parsing (via h11 / h2). The Python wrapper code
  is the only place mgf-http adds Python overhead.
- **PC-INV-2** — Header redaction set is a **frozenset** at
  module level
  ([`_client.py:50-61`](../../src/mgf/http/_client.py#L50)).
  Set membership is O(1); no per-call rebuild.
- **PC-INV-3** — `RetryPolicy` is a **frozen dataclass with
  slots**. Memory footprint per instance ~280 bytes. No
  hidden state.
- **PC-INV-4** — Backoff jitter uses `random.random()` not
  `secrets` — fast (~50 ns) per call. Cryptographic randomness
  is not required for load-shedding.
- **PC-INV-5** — `_NullSpan` is the OTel no-op shape
  ([`_client.py:479-494`](../../src/mgf/http/_client.py#L479))
  — when OTel isn't installed, span ops are sub-µs context-
  manager enter/exit.
- **PC-INV-6** — Connection pool is **owned by httpx**;
  pool size + idle-timeout + keepalive are httpx-config
  concerns. We don't reinvent the pool semantics.

---

## §4 Work units

### PC-01 Per-call wrapper overhead pin

**Why:** `AsyncHttpClient.request`
([`_client.py:189-238`](../../src/mgf/http/_client.py#L189))
adds work over `httpx.AsyncClient.request`:

1. `method.upper()` — 1 op.
2. Look up retry policy (cached on self).
3. Per-call timeout select.
4. `_client_span(method, url)` — OTel span open.
5. Inside `_send_with_retries`:
   - `is_method_retryable(method)` — set membership.
   - Cookie pack (if cookies non-empty).
   - `_client.request(...)` — THIS is the actual httpx
     work.
   - `_log_response(...)` — log emit + header redaction.
6. Span close.

Estimated wrapper tax: ~10-30 µs per call beyond pure httpx.

For a consumer making 100 req/s, the wrapper takes ~3 ms/sec
of CPU. At 10 000 req/s (very high for Python), 300 ms/sec.

**Concrete output:**

- Add `tests/perf/test_wrapper_overhead.py`:
  - Mock httpx response cycle (zero-cost respx).
  - Drive 10 000 `await client.get(...)` calls.
  - Measure per-call wrapper time (excluding httpx).
  - Assert ≤ 50 µs per call.
- Document in benchmarks.md.

**Locked in by:** the perf test.

**Cross-references:** mgf-common PC-02 (per-record logging
budget — the structured log line emitted by `_log_response`).

**Status:** ✅ closed (2026-05-16). See §5.

---

### PC-02 Connection pool capacity profile

**Why:** `httpx.AsyncClient(...)` defaults: `limits=Limits(
max_connections=100, max_keepalive_connections=20)`. For:

- A single-target consumer (hits one host): pool maxes at
  ~20 keepalive connections; sustained 100 req/s fine.
- A many-target consumer (apiprobe-style; hits 10 hosts):
  pool divides; per-host capacity drops to ~2 keepalive.
- A long-running daemon making bursty requests: pool grows
  during bursts, shrinks during idle.

mgf-http exposes httpx's `limits` via the `**extra` kwarg
to `httpx.AsyncClient(...)` — but doesn't surface as a
top-level kwarg.

No measurement of actual capacity behavior.

**Concrete output:**

- Add `tests/perf/test_pool_capacity.py` (under
  `pytest.mark.slow`):
  - Build a client; drive sustained 100 req/s for 10 s
    against a localhost mock server.
  - Measure: peak pool size, peak memory, p95 latency.
  - Assert no growth past expected (memory steady within 5%).
- Document the consumer-side knobs (limits=, max_connections=)
  in the recipe doc.

**Locked in by:** the capacity test catches regressions in
pool behavior; the recipe doc helps consumers tune.

**Cross-references:** RA-06 (httpx client pool reuse across
suites — similar concern at mgf-apiprobe).

**Status:** ✅ closed (2026-05-16). Scope narrowed from the
original "pool capacity" framing to "wrapper resource
discipline under sustained load" — the httpx pool is owned by
httpx (PC-INV-6). See §5.

---

### PC-03 User-Agent computation cache

**Why:** `_default_user_agent`
([`_client.py:381`](../../src/mgf/http/_client.py#L381))
runs at `AsyncHttpClient.__init__` time:

1. `importlib.metadata.version("mgf-http")` — file-system
   read on first call; cached on subsequent.
2. `mgf.common.app_name()` + `app_version()` — contextvar +
   getattr.
3. f-string format.

Per-construction: ~50-200 µs (the metadata read is the
slow part on first call).

For a CI run that builds many `AsyncHttpClient` instances
(test fixtures, mgf-apiprobe suite runs), the cost compounds.
First call pays the full version-read; subsequent calls
hit the cache.

**Concrete output:**

- Module-level memoise the version read:
  ```python
  @functools.lru_cache(maxsize=1)
  def _lib_version() -> str:
      try:
          return importlib.metadata.version("mgf-http")
      except importlib.metadata.PackageNotFoundError:
          return "0.0.0"
  ```
- Then `_default_user_agent` calls `_lib_version()` cheaply.
- Add `tests/perf/test_user_agent_construction.py`:
  - Build 1000 clients.
  - Assert per-construction time ≤ 50 µs.

**Locked in by:** the perf test + the memoise pattern.

**Cross-references:** AP-13 (version-in-sync — the version
string is the pinned contract).

**Status:** ✅ closed (2026-05-16). See §5.

---

### PC-04 Header redaction set lookup

**Why:** `_redact_headers`
([`_client.py:413-424`](../../src/mgf/http/_client.py#L413))
iterates the dict + checks each key:

```python
for key, value in headers.items():
    if key.lower() in _SECRET_HEADER_KEYS:
        out[key] = "<redacted>"
    else:
        out[key] = value
```

Per-call: O(N) in header count. For typical 5-20 headers:
~5-50 dict + set ops. Sub-microsecond total.

Pin to catch regressions where redaction somehow becomes
linear-in-set-size (e.g., a future refactor that uses a
list instead of frozenset).

**Concrete output:**

- Add `tests/perf/test_header_redaction.py`:
  - Build a 20-header dict.
  - Loop 100 000 calls.
  - Assert per-call ≤ 2 µs.

**Locked in by:** the perf test.

**Cross-references:** SH-INV-2 (redaction-set invariant).

---

### PC-05 Retry policy iteration cost

**Why:** `_send_with_retries`
([`_client.py:241-374`](../../src/mgf/http/_client.py#L241))
calls `retry_policy.is_method_retryable(method)` + (per
retry) `is_status_retryable(response.status_code)` +
`backoff_for_attempt(attempt - 1)`. Each is sub-µs (frozenset
membership + arithmetic + random).

Pin to catch regressions.

**Concrete output:**

- Add `tests/perf/test_retry_policy_lookup.py`:
  - Construct a RetryPolicy.
  - Loop 100 000 `is_method_retryable` calls.
  - Loop 100 000 `is_status_retryable` calls.
  - Loop 10 000 `backoff_for_attempt(N)` calls.
  - Assert each operation ≤ 1 µs.

**Locked in by:** the perf test.

**Cross-references:** EH-11 (retry-with-jitter rule).

---

### PC-06 Sustained throughput 1000 req/s

**Why:** No sustained-throughput test exists. A consumer
making 100 req/s for 60 s should see:

- Stable memory (no leak).
- Stable p95 latency (no GC pause cliffs).
- Stable pool size (no leaked connections).

Without a sustained test, the wrapper could have a
slow-leak pattern that doesn't surface until a long-running
consumer hits it in production.

**Concrete output:**

- Add `tests/perf/test_sustained_throughput.py` under
  `pytest.mark.slow`:
  - Build a client.
  - Drive 100 req/s for 60 s against a localhost mock.
  - Measure: total requests, p50/p95/p99 latency, RSS
    delta, GC pause sum.
  - Assert: ≥6000 successful requests; p95 latency stable
    (within 20% of cold first-100 requests); RSS delta
    ≤10 MB; no GC pause >50 ms.
- Document in benchmarks.md.

**Locked in by:** the slow test (nightly, not per-PR).

**Cross-references:** mgf-common PC-02 (sustained throughput
for logging — parallel pattern at a different layer).

---

## §5 Closed items

- **PC-01** — `AsyncHttpClient.request` per-call wrapper overhead
  pinned at ≤ 50 µs (measured ~24 µs locally; ~2x cushion). Two
  perf-marker tests: a plain GET and a realistic POST with
  headers + json body. Mocks at `httpx.AsyncClient.request`
  via `AsyncMock` to exclude httpx's own protocol cost — the
  measurement isolates the cost OWNED by mgf-http (log emission,
  header redaction, retry-policy lookup, OTel span, Response
  handling). Tracker originally suggested respx but respx
  intercepts at the transport layer so httpx's protocol code
  still runs — that would measure "wrapper + httpx", not
  "wrapper over httpx"; the methodology choice is documented in
  the test docstring (parallels mgf-apiprobe PC-02). Added
  `tests/perf/test_wrapper_overhead.py`. (2026-05-16, commit
  pending.)
- **PC-02** — Wrapper resource discipline under sustained load
  pinned via a `slow`-marker test (RSS delta ≤ 10 MB over 5000
  requests + latency-drift ratio ≤ 3x between first-quarter p50
  and second-half p95). Scope is narrowed from the tracker's
  original "pool capacity" framing — the httpx pool is owned by
  httpx (PC-INV-6) and out of scope; what we OWN is the
  wrapper's per-request resource discipline, which is what this
  test gates. Local baseline: <1 MB RSS delta, ~24 µs p50 →
  ~30 µs p95. Catches per-request leaks in `_log_response`,
  header redaction, or retry bookkeeping. Added
  `tests/perf/test_pool_capacity.py`. (2026-05-16, commit
  pending.)
- **PC-03** — `_default_user_agent` per-call cost pinned at
  ≤ 50 µs (measured ~2 µs locally post-fix; ~25x cushion). Pre-
  fix: ~107 µs/call (importlib.metadata dispatcher cost). Fix:
  introduced `_lib_version()` helper memoised via
  `@functools.lru_cache(maxsize=1)`, so the package-version
  metadata read happens once per process instead of per call.
  `_default_user_agent` calls `_lib_version()` cheaply on
  subsequent invocations. Added
  `tests/perf/test_user_agent_construction.py` with two perf-
  marker tests: budget assertion AND a memoisation-invariant
  assertion (`_lib_version()` returns the same object on
  subsequent calls — if a future refactor drops the lru_cache,
  this contract test fails loud). (2026-05-16, commit pending.)

**Infrastructure changes:**

- Registered `perf` + `slow` markers + addopts deselect in
  `pyproject.toml` (matches mgf-common's convention — both run
  via `pytest -m perf` / `pytest -m slow` for the nightly sweep,
  not per-PR).
- Added `_lib_version()` helper in `src/mgf/http/_client.py`
  (PC-03 fix; load-bearing for the User-Agent perf budget).

- **PC-04** — `_redact_headers` per-call cost pinned at ≤ 2 µs
  over a realistic 20-header request (measured ~0.6 µs locally;
  ~3x cushion). The redaction shape is O(1) per header today —
  one `str.lower()` + one frozenset membership; the perf test
  catches a regression that would replace either with a
  list / regex / multi-key match (any of which would slow
  `_log_response` linearly with the redaction-set size). Added
  `tests/perf/test_header_redaction.py` with two perf-marker
  tests: the per-call budget assertion + a redaction-correctness
  pin (the budget would trivially pass on a no-op redactor;
  the correctness pin keeps the perf test honest). (2026-05-17,
  commit pending.)
- **PC-05** — `RetryPolicy` lookup methods pinned at ≤ 1 µs/call
  each (measured 0.15-0.4 µs locally; 3-6x cushion). Three perf-
  marker tests, one per hot-path method: `is_method_retryable`,
  `is_status_retryable`, `backoff_for_attempt`. The retry
  policy is a frozen+slotted dataclass with frozenset members;
  the budget catches a regression where any of those collapse
  to O(N) (e.g., frozenset → list refactor). Added
  `tests/perf/test_retry_policy_lookup.py`. (2026-05-17, commit
  pending.)

### Deferred to v1.1+

- **PC-06** — Sustained-throughput slow-marker test. Deferred
  to v1.1+: PC-02 already pins the wrapper's resource discipline
  + latency stability over 5000 requests (RSS delta ≤ 10 MB,
  latency drift ratio ≤ 3x) — the no-leak / no-drift contract
  is already locked in. PC-06's 60s time-paced shape (100 req/s
  for 60 s = 6000 requests) is incremental to that contract,
  not a separate behaviour; bumping PC-02's request count or
  adding a time-paced variant is a v1.1 polish step, not a
  v1.0 blocker. Re-evaluate when consumers surface a real
  long-duration leak pattern PC-02's window doesn't catch.
  (2026-05-17.)

---

## §6 Notes on methodology

This first pass walked the three source files (~430 LOC)
against:

- mgf-common's PC tracker as methodology template.
- httpx's documented connection-pool + transport
  characteristics.

The audit method:

1. Identify the wrapper's per-call work vs httpx's per-call
   work.
2. Pin the wrapper tax separately from httpx's cost.
3. Add capacity tests for sustained-throughput patterns
   not surfaced by single-call budgets.

Six items + six invariants. Future passes should look for:

- **HTTP/2 multiplexing.** When httpx defaults to HTTP/2,
  pool behavior differs (one connection multiplexes many
  streams). PC-02's capacity profile shifts.
- **httpx 1.0 transition.** Major version bump; re-audit.
- **Async-context overhead.** Some consumers wrap many
  small `client.get(...)` calls in tight loops. The
  per-call wrapper tax becomes the bottleneck. Profile.

When an item closes, move it to §5 with the closing-commit
SHA + measured baseline.
