# 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: walked the
> ten source files focused on per-request middleware overhead,
> dependency-resolution cost, HMAC computation latency, and
> lifespan startup time.
>
> **Scope:** mgf-fastapi's OWN performance footprint. NOT a
> re-audit of mgf-common's PC items. NOT an audit of FastAPI
> / Starlette / pydantic / uvicorn.
>
> **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), [`mgf-django/docs/inprogress/PERFORMANCE_AND_CAPACITY.md`](https://codeberg.org/magogi-admin/mgf_django/src/branch/main/docs/inprogress/PERFORMANCE_AND_CAPACITY.md)
> (sister-sibling — middleware-overhead pattern parallels).

The sibling sits on the request path of every FastAPI consumer.
Five sub-systems contribute per-request work: RequestIdMiddleware,
ExceptionTranslationMiddleware (only on the exception path),
the dependencies (get_app_context / get_settings /
get_request_id), the security primitives (bearer_session,
IpAllowlist), and the webhook verifier on hits to the
webhook routes.

---

## §1 You are here

mgf-fastapi ships ~15 public names; ASGI middleware via
`starlette.middleware.base.BaseHTTPMiddleware` (slightly
slower than pure ASGI middleware but more ergonomic); HMAC
verification with single SHA-256 compute per request;
bearer-session resolution one IdP-roundtrip per protected
endpoint; IpAllowlist CIDR membership scan O(N) in policy
size.

What this tracker exposes is the **per-request budget for
mgf-fastapi's added layers** — how much overhead an
mgf-fastapi-using app pays compared to bare FastAPI, plus
the load-bearing one-shot ops (HMAC, IP scan) that hit on
specific routes.

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

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

**Do first:** PC-01..04 closed (P1). PC-05 (IpAllowlist 100-CIDR
scan) + PC-06 (Exception MRO lookup) closed 2026-05-17. PC-07
(run_test_app startup overhead) deferred to v1.1+.

---

## §2 Priority table

| ID | Theme | One-liner | Effort | Status |
|---|---|---|---|---|
| [**PC-01**](#pc-01-requestidmiddleware-per-request-overhead) | Latency | `BaseHTTPMiddleware.dispatch` has known overhead (~20-50 µs over pure-ASGI). RequestIdMiddleware adds dict.get + contextvar set/reset + header set. Quantify total budget. | S | ✅ closed |
| [**PC-02**](#pc-02-hmac-verifier-latency-budget) | Latency | `HmacWebhookVerifier.verify` runs SHA-256 over `<id>.<ts>.<body>`. For a 4 KB webhook body, SHA-256 is ~5-10 µs. The base64 + constant-time-compare adds ~1 µs each. Total budget ≤ 50 µs per call. | S | ✅ closed |
| [**PC-03**](#pc-03-bearer-session-resolution-time) | Latency | `bearer_session` dep extracts the token (~1 µs) + calls `verifier.verify_session(token)`. The verifier IS the cost (IdP roundtrip, JWT verify, etc.); the wrapper overhead is what THIS tracker measures. | XS | ✅ closed |
| [**PC-04**](#pc-04-lifespan-startup-time) | Startup | `setup_lifespan(...)` runs `mgf.common.bootstrap(...)` inside the FastAPI lifespan. For test fixtures that build fresh apps repeatedly (PAPER-28's `run_test_app`), the lifespan cost compounds. | S | ✅ closed |
| [**PC-05**](#pc-05-ipallowlist-cidr-scan-cost) | Latency | `IpAllowlist.__call__` iterates `self._networks` checking `client_ip in network`. O(N) in policy size. For a deny-everything-but-loopback allowlist (default), N is small (2 entries). For a "trust all our prod ranges" allowlist with hundreds of CIDRs, the per-request scan dominates. | XS | ✅ closed |
| [**PC-06**](#pc-06-exception-translation-mro-walk-cost) | Latency (exception path) | `ExceptionTranslationMiddleware._lookup_status` walks `exc_type.__mro__` checking the status_map. Per call: O(MRO depth × map size). On the happy path zero cost (the middleware only runs on exception); on the exception path it's microseconds. | XS | ✅ closed |
| [**PC-07**](#pc-07-run-test-app-startup-overhead) | Test-harness latency | `run_test_app` picks a free port + spawns uvicorn + waits for server.started. Per-call overhead matters for e2e tests with many fixtures. | S | ⏳ v1.1+ — uvicorn startup is the dominant cost; library-side mitigation requires deeper changes to the harness shape |

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** — HMAC uses **`hashlib.sha256` + `hmac.compare_digest`**
  ([`webhooks/_verifier.py:184-185`](../../src/mgf/fastapi/webhooks/_verifier.py#L184)).
  Both are CPython C implementations; latency dominated by
  payload size, not Python overhead.
- **PC-INV-2** — `IpAllowlist` parses CIDRs **once at
  construction** with `@lru_cache`
  ([`security.py:70-71`](../../src/mgf/fastapi/security.py#L70)).
  Per-request work is just `ip in network`, no parsing.
- **PC-INV-3** — Dependency resolution is **state-read only**.
  `get_app_context` / `get_settings` / `get_request_id` all
  do `getattr(request.app.state, ...)` or
  `getattr(request.state, ...)` — no allocation, no I/O.
- **PC-INV-4** — Lifespan **runs once per app instance**.
  Multi-worker uvicorn forks pay it per worker; within a
  worker, zero per-request cost.
- **PC-INV-5** — Contextvar reset is in `finally`
  ([`_request_id.py:71-75`](../../src/mgf/fastapi/_request_id.py#L71)).
  Exception-path performance is the same as happy-path
  (the finally adds one branch).
- **PC-INV-6** — Redactor + JsonFormatter inherited from
  mgf-common. Per-log-record cost tracked in mgf-common PC-02.

---

## §4 Work units

### PC-01 RequestIdMiddleware per-request overhead

**Why:** `RequestIdMiddleware.dispatch`
([`_request_id.py:60-79`](../../src/mgf/fastapi/_request_id.py#L60))
extends `BaseHTTPMiddleware`. Starlette's documented note:
`BaseHTTPMiddleware` has a ~20-50 µs overhead per request
compared to pure-ASGI middleware (it wraps the call into a
threaded context). On top of that, our dispatch adds:

1. `request.headers.get(REQUEST_ID_HEADER.lower())` — header dict get.
2. `_generate_request_id()` if absent — UUID4 allocation.
3. `request.state.request_id = request_id` — attr set.
4. `_REQUEST_ID_VAR.set(...)` — contextvar set.
5. `await call_next(request)` — the chain.
6. `_REQUEST_ID_VAR.reset(token)` — contextvar reset.
7. `response.headers[REQUEST_ID_HEADER] = request_id` — dict set.

Estimated total: ~25-70 µs per request beyond bare FastAPI.

**Concrete output:**

- Add `tests/perf/test_request_id_middleware.py`:
  - Build a FastAPI app with the middleware + a zero-cost
    handler.
  - Drive 10 000 requests via `httpx.AsyncClient`.
  - Measure per-request time.
  - Assert ≤ 100 µs per request including the
    BaseHTTPMiddleware overhead (generous initial budget).
- Document in `docs/ops/benchmarks.md` (create the file).

**Locked in by:** the perf test; regression breaks the
nightly perf-sweep.

**Cross-references:** mgf-django PC-01 (sister-sibling
parallel); Starlette's BaseHTTPMiddleware perf docs.

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

---

### PC-02 HMAC verifier latency budget

**Why:** `HmacWebhookVerifier.verify`
([`webhooks/_verifier.py:119-188`](../../src/mgf/fastapi/webhooks/_verifier.py#L119))
runs:

1. Dict comprehension over headers (case-insensitive lookup).
2. Three header reads + UUID-ish event_id capture.
3. `int(ts_raw)` parse + replay-window check.
4. SHA-256 over `<id>.<ts>.<body>` — payload-size dominated.
5. base64 encode the digest.
6. Iterate signature candidates + `hmac.compare_digest`.

For a 4 KB webhook body: SHA-256 ~5-10 µs; for 64 KB body:
~50-100 µs. The Python overhead is ~5-10 µs (dict comp,
int parse, base64 encode).

Webhook endpoints are typically NOT the bottleneck of a
FastAPI app (they're rate-limited by the provider's send
rate). But quantifying the budget catches regressions.

**Concrete output:**

- Add `tests/perf/test_hmac_verifier_latency.py`:
  - Build a verifier with a known secret.
  - Generate valid signed payloads at: 1 KB, 16 KB, 64 KB,
    256 KB.
  - Measure per-call time for each.
  - Pin per-size budget (e.g., ≤50 µs for 1 KB; scale
    linearly).
- Document in benchmarks.md.

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

**Cross-references:** PAPER-26 retrospective; SH-INV-1
(constant-time compare).

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

---

### PC-03 bearer_session resolution time

**Why:** `bearer_session` returned dependency
([`security.py:249-284`](../../src/mgf/fastapi/security.py#L249))
runs per protected endpoint:

1. `_extract_bearer_token(request, cookie_name)` — header
   read + scheme parse, optional cookie fallback. ~2-5 µs.
2. `getattr(request.app.state, verifier_attr, None)` — ~1 µs.
3. `await verifier.verify_session(token)` — THE COST. Depends
   on the verifier: in-memory (~10 µs), JWT verify (~50-100 µs),
   IdP roundtrip (50-500 ms).

The wrapper's overhead is what's measurable here — the
verifier cost is consumer-side.

**Concrete output:**

- Add `tests/perf/test_bearer_session_wrapper.py`:
  - Mock `verifier.verify_session` as a no-op coroutine.
  - Drive 10 000 calls.
  - Measure wrapper overhead.
  - Assert ≤ 20 µs per call (excluding verifier work).
- Document.

**Locked in by:** the test pins the WRAPPER cost separately
from the verifier cost.

**Cross-references:** PAPER-29; RA-INV-7 (bearer_session
translates all failures uniformly — affects shape, not perf).

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

---

### PC-04 Lifespan startup time

**Why:** `setup_lifespan(...)` returns an async context
manager that runs `mgf.common.bootstrap(...)` at FastAPI
startup. mgf-common's bootstrap loads pydantic-settings, OTel,
redactor patterns; mgf-common PC-01 closed cold-import to
~100 ms.

For PAPER-28's `run_test_app`, the lifespan runs at each
test-server spawn. For pytest-asyncio fixtures that build
fresh apps per test, lifespan cost compounds:
1000 tests × 100 ms = 100 s of bootstrap time per test
suite.

mgf-common PC-01 helped; this measures the FastAPI-side
add-on (lifespan setup + app.state attach).

**Concrete output:**

- Add `tests/perf/test_lifespan_startup.py`:
  - Build a FastAPI app with `lifespan=setup_lifespan(...)`.
  - Enter + exit the lifespan in async context.
  - Measure total.
  - Assert ≤ 150 ms (mgf-common's 100 ms + sibling-side 50 ms).
- Document. Cross-reference mgf-common PC-01 closed.

**Locked in by:** the test pins the budget on top of
mgf-common's measured baseline.

**Cross-references:** mgf-common PC-01 (closed); PAPER-28.

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

---

### PC-05 IpAllowlist CIDR scan cost

**Why:** `IpAllowlist.__call__`
([`security.py:125-150`](../../src/mgf/fastapi/security.py#L125))
iterates `self._networks`:

```python
for network in self._networks:
    if client_ip.version == network.version and client_ip in network:
        return
```

`ip in network` is O(1) per check (range comparison). Per
call: O(N) in policy size where N is `len(self._networks)`.

For the default (LOOPBACK_CIDRS, 2 entries): negligible.
For a "trust all our prod CIDRs" allowlist (~100 entries):
~10-50 µs per check.

If the allowlist is on the request path of every API call
(admin-only routes, mostly), the cost adds up.

**Concrete output:**

- Add `tests/perf/test_ip_allowlist_scan.py`:
  - Build allowlists at 2, 10, 100, 1000 CIDR entries.
  - Drive 10 000 dep invocations against each.
  - Assert linear-in-policy-size growth + per-entry
    budget ≤ 1 µs.

**Locked in by:** the test pins the linear-growth invariant.

**Cross-references:** PAPER-27.

---

### PC-06 ExceptionTranslationMiddleware MRO walk cost

**Why:** `_lookup_status`
([`_exceptions.py:144-176`](../../src/mgf/fastapi/_exceptions.py#L144))
walks the exception class's MRO:

```python
for ancestor in exc_type.__mro__:
    if not isinstance(ancestor, type):
        continue
    if not issubclass(ancestor, AppError):
        continue
    if ancestor not in self._status_map:
        continue
    if ancestor is AppError:
        appbase_status = self._status_map[ancestor]
        continue
    return self._status_map[ancestor]
```

For a typical AppError subclass: MRO depth 3-5; status_map
size 8 default. Each iteration: 2 isinstance + 1 dict get.
Total: ~5-15 µs.

ONLY runs on the exception path. Happy-path: zero cost.

For an app that throws exceptions on, say, 1% of requests:
this is 0.01 × ~10 µs = 0.1 µs amortised per request. Not
a hot path.

But: if a consumer uses exceptions for normal flow (rare
but possible — pydantic ValidationError, etc.), the
amortised cost shifts.

**Concrete output:**

- Add `tests/perf/test_exception_translation_mro.py`:
  - Construct synthetic AppError subclass trees of depth
    1-5.
  - Per leaf, call `_lookup_status` × 10 000.
  - Assert ≤ 20 µs per call.

**Locked in by:** the test.

**Cross-references:** PAPER-24; RA-07 (the property-test
for correctness).

---

### PC-07 run_test_app startup overhead

**Why:** `run_test_app`
([`testing.py:47-…`](../../src/mgf/fastapi/testing.py#L47))
per-spawn cost:

1. `_free_port()` — bind ephemeral, release, return port.
   ~1-5 ms.
2. Build uvicorn Config + Server.
3. `await server.serve()` start; await `server.started`.
4. Yield URL.

Per-test cost typically 200-500 ms. For a suite with 100
e2e tests each spawning their own server, that's 20-50 s
of just-uvicorn-startup time.

The PAPER-28 retrospective notes the helper exists to
amortise this if reused; consumers SHOULD share a single
test-app instance across many tests via fixture scope.

**Concrete output:**

- Add `tests/perf/test_run_test_app_startup.py`:
  - Spawn + tear down 10 servers; measure per-spawn time.
  - Assert ≤ 500 ms (generous).
- Update the recipe doc to emphasise the "share via
  fixture" pattern.
- Document the measured baseline.

**Locked in by:** the perf test + the recipe doc.

**Cross-references:** PAPER-28; RA-05 (free-port TOCTOU
— bundled fix opportunity).

---

## §5 Closed items

- **PC-01** — RequestIdMiddleware net per-request overhead pinned
  at ≤ 200 µs vs. bare FastAPI (measured ~84 µs locally;
  ~2.4x cushion). The tracker proposed 100 µs, but local
  measurement of the with/without delta came in at ~84 µs —
  19 % cushion would have been too tight for CI noise + slower
  hardware. Budget raised to 200 µs and documented in the test
  docstring. The elevated baseline reflects BaseHTTPMiddleware's
  documented overhead + httpx-ASGI variance in the measurement
  loop. Added `tests/perf/test_request_id_middleware.py` —
  builds two FastAPI apps (with + without the middleware),
  drives each via `httpx.AsyncClient` + ASGITransport, takes
  the delta. (2026-05-16, commit pending.)
- **PC-02** — `HmacWebhookVerifier.verify` per-call latency
  pinned per-body-size to catch non-linear regressions
  (a redundant body pass, a regex, a re-encode would breach the
  larger sizes first). Budgets at 1 KB / 16 KB / 64 KB / 256 KB
  give 8-30x cushion against measured ~1.7 / 5.3 / 16 / 59 µs.
  Added `tests/perf/test_hmac_verifier_latency.py` — generates
  valid Svix-signed payloads at each size, runs 1000 verifies,
  asserts each size under its budget; on breach the error
  message lists every breaching size for fast triage. (2026-05-16,
  commit pending.)
- **PC-03** — `bearer_session` dependency wrapper overhead
  pinned at ≤ 20 µs/call with a no-op verifier (measured
  ~0.6 µs locally; ~35x cushion). The wrapper measurement
  EXCLUDES the verifier cost (mocked away as a no-op coroutine)
  so the gate isolates what mgf-fastapi owns: token extraction
  + verifier lookup + dependency-shape glue. Added
  `tests/perf/test_bearer_session_wrapper.py`. (2026-05-16,
  commit pending.)
- **PC-04** — `setup_lifespan` enter+exit wall-clock pinned at
  ≤ 150 ms (max-of-5 strategy; measured ~24 ms max + ~1 ms
  subsequent locally). Catches regressions in either the first
  invocation (which pays mgf.common bootstrap setup) or
  subsequent invocations (where in-process state caches the
  work). Added `tests/perf/test_lifespan_startup.py`. Cross-
  references mgf-common PC-01 as the cornerstone baseline.
  (2026-05-16, commit pending.)

**Infrastructure changes:**

- Registered `perf` marker + addopts deselect in `pyproject.toml`
  (matches mgf-common's convention — perf pins run via
  `pytest -m perf` for the nightly sweep, not per-PR).

---

## §6 Notes on methodology

This first pass walked the ten source files (~900 LOC)
against:

- mgf-common's PC tracker as methodology template.
- Starlette's documented `BaseHTTPMiddleware` overhead
  characteristics.
- PAPER-26/27/28/29 retrospectives — each shipped paper
  carries a perf-shape claim that should be pinned.

The audit method:

1. Read each source file end-to-end identifying per-call
   work.
2. Quantify expected order-of-magnitude (sub-µs / µs / ms /
   s) based on the operations involved.
3. For every concern, write a falsifiable budget pinned by
   a `pytest.mark.perf` test.
4. Cross-reference mgf-common PC items + sister-sibling
   patterns.

Seven items + six invariants. The sibling's perf surface is
the densest among federation siblings (multiple middleware,
multiple deps, the HMAC primitive). Future passes should
look for:

- **HTTP/2 + HTTP/3** — if the consumer apps upgrade, the
  middleware overhead profile shifts.
- **Async-dep chain costs.** FastAPI's dep-injection system
  resolves deps per request. As the federation siblings
  add more `Depends(...)`-shaped utilities, the chain
  grows.
- **Sustained-throughput tests.** Currently each PC item
  pins a single-call budget. A sustained 1000 req/s for
  10 s would catch memory leak / GC pause patterns the
  single-call tests miss.

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