> ## Documentation Index
> Fetch the complete documentation index at: https://docs.neobank.proofof.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Health & metrics

> Liveness and readiness probes, Prometheus metrics, rate limits, and the security headers attached to every response.

# Health & metrics

The API exposes two health probes, a bearer-gated Prometheus endpoint, and a per-client
rate limiter. Every response — healthy or not — carries a fixed set of security headers,
and every error is returned as an RFC 7807 problem document.

## Health probes

Two probes are registered on the FastAPI app, both with `include_in_schema=False` so they
do not appear in the public OpenAPI document:

```python apps/api/src/neobank/main.py theme={null}
@app.get("/health/live", tags=["Health"], include_in_schema=False)
async def live() -> dict[str, str]:
    return {"status": "ok"}

@app.get("/health/ready", tags=["Health"], include_in_schema=False)
async def ready(request: Request) -> dict[str, str]:
    async with request.app.state.database.engine.connect() as connection:
        await connection.exec_driver_sql("SELECT 1")
    await request.app.state.session_store.ready()
    return {"status": "ready"}
```

| Probe     | Path                | Checks                                            | Success body         |
| --------- | ------------------- | ------------------------------------------------- | -------------------- |
| Liveness  | `GET /health/live`  | Process is serving                                | `{"status":"ok"}`    |
| Readiness | `GET /health/ready` | Database (`SELECT 1`) and session store readiness | `{"status":"ready"}` |

Liveness confirms the process is up; readiness confirms it can serve a real request. Wire
`/health/live` to a liveness probe and `/health/ready` to a readiness probe in Kubernetes
or your load balancer — they are exempt from the rate limiter (see below), so probe traffic
does not consume any client's budget.

## Prometheus metrics

```
GET /internal/metrics
```

The endpoint returns the default Prometheus exposition. Two series are emitted by the
request-context middleware:

| Metric                                  | Type      | Labels             | Meaning                          |
| --------------------------------------- | --------- | ------------------ | -------------------------------- |
| `neobank_http_requests_total`           | counter   | `method`, `status` | HTTP requests handled by the API |
| `neobank_http_request_duration_seconds` | histogram | `method`           | HTTP request duration            |

The endpoint is gated by a static bearer token. If `NEOBANK_METRICS_TOKEN` is unset, or
the `Authorization` header does not match `Bearer <token>`, the endpoint returns **404**
rather than 401 — it does not advertise that it exists:

```python apps/api/src/neobank/main.py theme={null}
@app.get("/internal/metrics", include_in_schema=False)
async def metrics(request: Request) -> Response:
    expected = f"Bearer {resolved_settings.metrics_token}"
    supplied = request.headers.get("Authorization", "")
    if (
        not resolved_settings.metrics_token
        or not secrets.compare_digest(supplied, expected)
    ):
        return Response(status_code=404)
    return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
```

<Note title="The edge returns 404 for /internal/*">
  Behind the single-server Caddy edge and the shipped Helm ingress, `/internal/*` is not
  routed publicly. On Kubernetes, only `networkPolicy.metricsNamespace` may reach API port
  8000 in addition to the ingress controller namespace. Scrape from that namespace with the
  32-character-or-longer bearer token. On the server, scrape inside the private network or
  through an authenticated sidecar.
</Note>

## Rate limiting

The rate limiter runs as ASGI middleware ahead of the router. `/health/*` and `/internal/*`
are exempt; every other request is limited:

```python apps/api/src/neobank/middleware.py theme={null}
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
    if scope["type"] != "http" or scope.get("path", "").startswith(("/health/", "/internal/")):
        await self.app(scope, receive, send)
        return
    method = str(scope.get("method", "GET"))
    limit = 30 if method in {"POST", "PUT", "PATCH", "DELETE"} else 120
    address = _rate_limit_address(scope)
    credential = _rate_limit_credential(scope)
    allowed = await self.limiter.allow(f"ip:{address}", limit)
    if allowed and credential:
        allowed = await self.limiter.allow(f"credential:{credential}", limit)
```

| Method class                              | Limit | Window     |
| ----------------------------------------- | ----- | ---------- |
| Unsafe (`POST`, `PUT`, `PATCH`, `DELETE`) | 30    | Per minute |
| All other methods                         | 120   | Per minute |

The limit is keyed **first by client address, then by credential** (the `Authorization`
header or session cookie). A request consumes budget on both buckets when a credential is
present, so a single token cannot bypass the per-IP ceiling and a single IP cannot exhaust
a per-token ceiling alone.

Uvicorn accepts `X-Forwarded-For` only from `NEOBANK_TRUSTED_PROXY_IPS`. The server Compose
deployment pins Caddy to `172.30.255.2` and trusts only that address. The Helm value
`config.trustedProxyIps` must match the source address or CIDR of the ingress controller
pods; the NetworkPolicy separately limits port 8000 ingress to
`networkPolicy.ingressNamespace`. Never use `*`, `0.0.0.0/0`, or `::/0`. A mismatched value causes callers to
share the proxy address bucket; an overbroad value lets another network peer spoof client
addresses.

When a bucket is exhausted the middleware returns a 429 as an RFC 7807 problem document
with a `Retry-After: 60` header:

```python apps/api/src/neobank/middleware.py theme={null}
response = JSONResponse(
    {
        "type": settings.problem_type("rate_limited"),
        "title": "Rate limit exceeded",
        "status": 429,
        ...
        "code": "rate_limited",
        "retryable": True,
    },
    status_code=429,
    media_type="application/problem+json",
    headers={"Retry-After": "60"},
)
```

In secure runtimes the limiter is backed by Redis; in development it falls back to an
in-memory counter. The Redis path is what makes the limit coherent across multiple API
replicas.

## Headers on every response

The request-context middleware attaches five headers to every HTTP response, including
errors:

```python apps/api/src/neobank/middleware.py theme={null}
headers["X-Request-ID"] = request_id
headers["X-Content-Type-Options"] = "nosniff"
headers["Referrer-Policy"] = "same-origin"
headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
headers["Cache-Control"] = "no-store"
```

* `X-Request-ID` — generated per request (or echoed when the caller supplies one); the
  same value is written to the audit trail and the structured log line. Use it to
  correlate a user report with logs and an [Audit](operate/audit) row.
* `X-Content-Type-Options: nosniff` — prevents MIME sniffing on responses.
* `Referrer-Policy: same-origin` — the API origin is not leaked as a referrer.
* `Permissions-Policy` — denies camera, microphone, and geolocation to the API origin.
  The WebAuthn entries for `app.perflo.ai` live on the web app's edge headers, not here;
  see [Perflo hosted origins](customize/perflo-origins).
* `Cache-Control: no-store` — API responses are never cached.

The web app's edge (`apps/web/security-headers.conf`) adds the CSP and the WebAuthn
`Permissions-Policy` entries in addition to these.

## Errors are problem documents

Every error — validation failures, auth rejections, provider errors, rate limits — is
serialized as `application/problem+json` with the `ProblemDetails` shape (`type`, `title`,
`status`, `detail`, `instance`, `code`, `request_id`, `retryable`, `submission_uncertain`).
The `retryable` and `submission_uncertain` flags tell callers whether to retry and whether
a retried financial submission could double-spend; see
[Confirmation & idempotency](integrate/confirmation-idempotency).

## Related

* [Audit](operate/audit) — correlate an `X-Request-ID` against the audit trail.
* [Confirmation & idempotency](integrate/confirmation-idempotency) — what
  `submission_uncertain` means for retries.
* [Perflo hosted origins](customize/perflo-origins) — the WebAuthn `Permissions-Policy`
  entries on the web edge.
