Skip to content

fix!: client leaks, log hygiene, CI gaps, upgraded dependencies and a Python 3.14 floor - #156

Merged
aditeyabaral merged 21 commits into
pesu-dev:devfrom
aditeyabaral:fix/client-leak-and-csrf-prefetch
Sep 12, 2026
Merged

aditeyabaral merged 21 commits into
pesu-dev:devfrom
aditeyabaral:fix/client-leak-and-csrf-prefetch

Conversation

@aditeyabaral

@aditeyabaral aditeyabaral commented Sep 12, 2026

Copy link
Copy Markdown
Member

📌 Description

Fixes four problems found while reviewing #155. All four predate that PR, and none of them fail a test today — which is why they survived.

# Problem Commit
1 httpx.AsyncClient leaked on every failed authentication 43ddb7d
2 Every /authenticate made two upstream CSRF fetches and discarded one 40b6107
3 Staging could not be redeployed by hand f569aed
4 README typed /health's status as str; ruff targeted py314 against requires-python >=3.12 6204d6b
5 Nothing enforced a version bump, so releases were easy to forget 5e79b19
6 Client cleanup could mask the real error; shutdown leaked prefetched clients; a cold cache fetched under the lock d4fe4e6
7 Every wrong password logged a full stack trace at ERROR 15458c3
8 Rejected prod deploy hooks reported success; all actions ran on deprecated Node 20 ebf70df
9 The validation handler logged submitted passwords in plaintext 1a39d82
10 Client cleanup was abandoned part-way if a second cancellation landed during it 00aa9ca
11 app/app.py:74-75 was the only uncovered code left in the project ca68745
12 docker/login-action sat on v3 while everything around it moved; lint ran three identical jobs; three different ruff versions were in use at once d10dcdb, 851dd89
13 httpx is abandoned at 0.28.1, and starlette 1.6.0 deprecates it 0614f2f
14 requires-python promised >=3.12 while the image shipped 3.14 only, so CI validated two interpreters nobody deploys a31df98
15 The coverage gate never ran on a pull request eb08fd5, 18c101a

1. Client leaked on failure paths — the only correctness bug here

authenticate() closed its client only as its last statement, so every raise before that stranded the client and its connection pool until GC. The common case is a wrong password, which is routine traffic rather than an edge case. Two more instances of the same bug were in the same file: _fetch_new_client_with_csrf_token() leaked when the CSRF meta tag was missing or the GET raised, and _prefetch_client_with_csrf_token() leaked the freshly fetched client if it never reached the cache.

_fetch_new_client_with_csrf_token deliberately uses except/raise rather than try/finally, because on success the client is the return value.

Six of the seven new tests fail against the previous code; the seventh pins the success path, which was already correct.

2. Two prefetches per request

Two independent prefetch paths both fired on every call — one in _get_client_with_csrf_token(), one queued by the endpoint via background_tasks. The second closed the client the first had just cached. Both were introduced together in 13c1475, so this has been the behaviour since async support landed.

Verified in a container built from this branch: one GET /Academy/ per request where there were previously two (2 at startup from lifespan + the refresh loop, then exactly 1 per request). CSRF_TOKEN_REFRESH_LOCK is also gone — lifespan() always bypassed it, so it never provided mutual exclusion; PESUAcademy._csrf_lock still guards every read and write of the (client, token) pair, and the prefetch still fetches outside that lock so a 10s upstream timeout can't block requests.

The prefetch task is now strongly referenced (a bare create_task can be GC'd mid-flight) and its exceptions are retrieved and logged instead of surfacing as "Task exception was never retrieved" at GC.

3. Manual staging deploys

Adding workflow_dispatch alone would have failed silently: on a dispatch every github.event.workflow_run.* expression is empty, so the old if evaluates false and the job always skips. The condition now branches on github.event_name, and the manual path additionally requires github.ref_name == 'dev' so a dispatch can't ship an arbitrary branch to staging.

🔄 Supersedes #150

This takes #150's genuinely working parts — the concurrency group (re-keyed; #150 keys it on head_branch, empty on a dispatch), the stale-deploy guard, and the git merge --abort removal in deploy-prod.yaml.

#150's auto-version-bump is deliberately dropped, because it cannot work as written. It git pushes bump commits to dev, but dev is protected (required_approving_review_count: 1, enforce_admins: false), so the github-actions[bot] push is rejected — and since the bump step is sequenced before the deploy step, staging would never deploy at all. The same applies to its sync-minor-to-dev job. Making it work would need an admin bypass credential in CI, which isn't a good trade for automatic version bumping. scripts/bump_version.py is not carried over.

Two bugs found in #150 while folding it in, both fixed here rather than inherited:

  • Its stale-deploy guard is a no-op: a run: step ending in exit 0 succeeds and lets the deploy step run regardless, so it only skipped the bump lines that followed it in the same step. It's a step output consumed by if: here, so it actually skips.

Also fixed in the step being rewritten: curl -X POST without -f exits 0 on HTTP 4xx/5xx, so a rejected deploy hook reported "✅ deployment completed successfully". Now curl -fsS. The same latent false-green exists in the three curl calls in deploy-prod.yaml; left for a separate change.

5. Version bumps are now enforced rather than automated

Since #150's auto-bump can't work, this asks the author instead: version-check.yaml compares project.version against the base branch and fails the PR if it wasn't raised. Reading instead of writing sidesteps branch protection entirely, and it's more honest about semver — no script can tell a patch from a breaking change, but the person writing it can.

It also verifies uv.lock records the same version, since bumping pyproject.toml without re-running uv lock leaves the lockfile stale — a mistake easy to make by hand.

Deliberately not using a trigger-level paths: filter: a required status check whose workflow never runs stays pending forever and blocks the PR. The job always runs and decides inside itself.

Verified against all four outcomes — unchanged version fails, decreasing version fails, bumped-with-stale-lockfile fails, bumped-with-matching-lockfile passes.

Note

A workflow only reports until it is a required status check. version-bumped has since been
added to dev's branch protection, so it now blocks merges rather than merely reporting.

Version bumped (2602fdc, later raised to 4.1.0 — see the third round). The PR introducing the rule should be the first to follow it, not the first exempt from it.

🔁 Follow-up round

A self-review of the diff above turned up a failure mode this PR had itself introduced, which is fixed here along with the pre-existing issues found alongside it.

d4fe4e6 — client lifecycle. The new try/finally closed the client on every path, but if aclose() itself raised it replaced the body's exception, turning a routine 401 into a 500. All close sites now route through _close_client_quietly(), which logs instead of raising (CancelledError still propagates). Two more in the same area: close_client() left prefetch tasks running, so one could complete after the close and cache a client nobody would ever close — they are now cancelled and awaited first, and the close happens under _csrf_lock with both cache slots cleared. And _get_client_with_csrf_token no longer holds _csrf_lock across its cold-cache fetch, which used to queue every concurrent request behind a 10s upstream timeout for no benefit.

15458c3 — logging severity. Every wrong password wrote a full traceback at ERROR, which misrepresents a client mistake as a server fault, buries real errors, and pages anyone alerting on error rate. Severity now follows the status code: 4xx warns without exc_info, 5xx keeps the trace. Verified in a container — a wrong password and a malformed request each produce one WARNING line, and the whole log holds zero tracebacks where it previously held one per failed login. Also here: the unit TestClient fixture no longer makes a live network call per test (the integration one still does, deliberately).

ebf70df — CI. Both prod deploy-hook curls lacked -f, so a rejected hook reported "✅ deployment completed successfully". And every workflow ran on deprecated Node 20; bumped to actions/checkout@v5 / actions/setup-python@v6, confirmed by the warning count going 1 → 0 between runs.

After that round the suite stood at 99 tests, 99.43% coverage, app/pesu.py at 100%. One existing test was replaced rather than kept: test_prefetch_closes_new_client_when_caching_fails asserted that a broken cached client aborts a refresh, which is no longer true (and is better not being true) — it is replaced by a test for the broken-old-client path and one for the cancellation window it was really meant to protect.

1a39d82 — a plaintext password leak, self-inflicted and caught in review

Worth calling out plainly. 15458c3 made validation_exception_handler log exc.errors() so the failure was diagnosable. Each entry carries an "input" key, and for a missing required field pydantic sets it to the entire request body — so any request omitting username logged the caller's password:

Request data could not be validated: [{'type': 'missing', 'loc': ('body', 'username'),
  'msg': 'Field required', 'input': {'password': 'hunter2-actual-secret'}}]

Now only type, loc and msg are logged — all the diagnostic value, none of the submitted values. The response body was never affected (it is built from loc and msg only). There is a regression test that fails against the leaking version.

This is distinct from the username in failed-auth logs, which is deliberate and unchanged. Tracing a failed login back to an account is the point of that line; a password is not traceability data.

🔂 Third round

00aa9ca — the cancellation window this PR had left open. Every caller of
_close_client_quietly runs it from an except BaseException handler or a finally, which is
exactly where a second cancellation can land — a shutdown cancelling a task already unwinding
from its first one. A plain await client.aclose() there is abandoned part-way and the connection
pool is never released, so the leak this whole PR exists to fix came back under the one condition
the cleanup is most likely to run in. The close is now its own shielded task, held by a
module-level strong reference: it completes regardless, while CancelledError still propagates, so
cancellation semantics are unchanged. The new test drives the exact sequence — cancel while waiting
for the lock, then cancel again once the close is in flight — and fails against the previous
implementation
, where the close never finished.

ca68745 — 100% coverage. The last uncovered lines were the branch reporting a refresh task
that fails its own cancellation. The test stubs the loop with one that turns CancelledError into a
RuntimeError, and asserts both that it is logged and that shutdown carries on past it to close the
client.

d10dcdb + 851dd89 — CI. docker/login-action v3 → v4, actions/checkout v5 → v7,
actions/setup-python v6 → v7. The earlier round bumped only what the Node 20 deprecation
warning named, which is why login-action was left behind and the others stopped one major short.

lint.yaml also loses its Python matrix. Ruff is a self-contained binary whose results depend on
its own version and on tool.ruff.target-version, not on the interpreter installed beside it,
so the 3.12, 3.13 and 3.14 legs were running byte-identical work three times. (pre-commit.yaml's
matrix went too — see the fourth round below.)

Underneath that: the repo had three ruff versions in play at once — an unpinned
pip install ruff in lint.yaml, v0.12.7 in .pre-commit-config.yaml, and >=0.15.14 in
pyproject.toml — so a ruff release adding a rule could fail a PR that changed nothing. The job now
runs the ruff from uv.lock, and pre-commit autoupdate brings the hook to the same version.

Note

Collapsing a matrix renames the reported check, which silently breaks branch protection: the old
context stays required, never reports, and blocks every PR. dev's required contexts have
already been patched
to the 5 these workflows actually report — version-bumped,
pre-commit-checks, lint, build, validate-pr-origin — down from 9. #143 and #132 cannot
produce the renamed checks until they rebase onto post-merge dev, which they need to do anyway.

0614f2f — every dependency upgraded, and httpx migrated. uv lock --upgrade across the
board, with the pyproject.toml floors raised to what is actually locked so they stop understating
what the project needs. Notable: fastapi 0.136.1 → 0.141.1, starlette 1.0.1 → 1.6.0, uvicorn
0.47.0 → 0.52.4, pytest 9.0.3 → 9.1.1, ruff 0.15.14 → 0.16.7.

httpx is the one that is not a plain version bump. The distribution is frozen at 0.28.1; the
project continued under a new name at pydantic/httpx2, still
authored by Tom Christie, and starlette 1.6.0 imports httpx2 first and only falls back to httpx
with a StarletteDeprecationWarning. Staying put means staying on an abandoned distribution and
on the deprecated path through starlette's TestClient.

The API surface this project uses is unchanged — AsyncClient(follow_redirects, timeout), get,
post, aclose — so the migration is the import, the annotations and 29 test patch targets. No
application logic changed. Production picks up httpcore2 and truststore in place of httpcore;
httpx2-jsfetch resolves into the lockfile but is emscripten-only and never installs in the image.

Version bumped to 4.1.0 — minor rather than patch, because the runtime dependency set changes
even though no API does.

🔄 Fourth round — Python 3.14 only

requires-python said >=3.12 while the Dockerfile has always shipped python:3.14-alpine on
both stages. So CI spent three serial legs validating two interpreters that are never deployed,
and the project is not on PyPI, so nothing consumes it as a library with its own choice of
interpreter. It is a deployed service, and the standard for a service is to test what it ships.

The matrix also never produced a signal. Across every recorded failure of this workflow — 8,
going back to 2025 — the first leg failed and fail-fast cancelled the other two. Not once did a
leg fail while another passed. Combined with max-parallel: 1, the matrix only ever ran to
completion when it had nothing to report, and cancelled precisely the legs that could have revealed
a version-specific bug.

So the promise is narrowed to match reality (a31df98):

pyproject.toml requires-python >=3.12>=3.14; ruff target-version back to py314
uv.lock relocked at >=3.14
pre-commit.yaml matrix and its now-meaningless max-parallel dropped; single 3.14 job
lint.yaml sets up 3.14
README.md, CONTRIBUTING.md, PR template, bug-report template every 3.12 reference → 3.14

target-version = "py314" reverses part of 6204d6b in this same PR, which lowered it to py312
to match the old floor — it is correct at py314 again now, and verified zero-churn (ruff check
and ruff format --check both clean).

uv rebuilt the local environment to 3.14.4 when the floor changed, so the suite genuinely ran
on the target interpreter rather than on 3.13: 101 passed, 100.00% coverage, all 12 pre-commit
hooks green.
CI is down from 9 checks to 5.

Version stays 4.1.0. This project's version has tracked the HTTP API — 3.0.0 was camelCase keys,
4.0.0 was the KYCAS removal — and that is untouched here. This is a runtime-requirements change,
the same class as the httpx2 migration above. Flagged rather than assumed: raise it to 5.0.0 if you
would rather the dropped Python support read as major.

🔐 Fifth round — the gate that never ran, and secret blast radius

validate-pr-origin requires pull requests to come from a fork, and GitHub withholds secrets from
fork pull requests. So scripts/run_tests.py always took its no-secrets branch on a pull request —
and that branch passed no --cov-fail-under at all. Every pull request reported green having
enforced nothing about coverage and having skipped all 11 live tests. The gate and the live tests
only ever ran on a push to dev, after merge.

eb08fd5 + 18c101a — the gate applies on both paths. Measured before changing anything: the
no-secrets run is 90 passed / 11 deselected at 99.16%, so 95% holds with room to spare.

The first attempt at making the skip visible did not work, and the reason is worth recording:
pre-commit captures a hook's output and prints it only on failure, so the ::warning:: was
swallowed by the very mechanism it was meant to work around — confirmed against this PR's own run,
which created zero annotations. verbose: true on the hook fixes it, and as a side effect the
pytest summary and coverage total are now visible in CI at all, where they were previously hidden.
90 passed, 11 deselected no longer looks identical to a full 101-test run. Verified on this PR:
one warning: Live tests skipped annotation, and Total coverage: 99.16% in the log.

The live tests still do not run on a pull request, and that is now a deliberate choice. An
approval-gated pull_request_target job was built and then reverted in 247b803: it is the only
mechanism by which a fork pull request can see secrets, and it works by running untrusted code with
them. An approval gate reduces that to a judgement call a maintainer has to get right on every
push, for a narrow benefit — a broken live integration already fails the dev run, which blocks
the staging deploy before it reaches anything. CONTRIBUTING.md now says plainly that the live
tests are the contributor's job to run locally.

Also built in this round and then reverted in full at the maintainer's request: an
approval-gated pull_request_target job for the live tests (247b803), the staging and
production environments it depended on (956c8b8), and the per-job permissions declarations
that went with them (8c6df63). The deploy workflows now match dev exactly on permissions and
environments. What survives from this round is the coverage gate and the visibility fix above,
which stand on their own.

🧱 Type of Change

  • 🐛 Bug fix – Non-breaking fix for a functional/logic error
  • ✨ New feature – Adds functionality without breaking existing APIs
  • ⚠️ Breaking change – Introduces backward-incompatible changes (API, schema, etc.)
    (No API or schema change. Breaking for a native install only: requires-python rises to
    >=3.14 and httpx becomes httpx2. The deployed image is unaffected — it already ran 3.14.)
  • 📝 Documentation update – README, docstrings, OpenAPI tags, etc.
  • 🧪 Test suite change – Adds/updates unit, functional, or integration tests
  • ⚙️ CI/CD pipeline update – Modifies GitHub Actions, pre-commit, or Docker build
  • 🐢 Performance improvement – Speeds up auth, scraping, or reduces I/O
  • 🕵️ Debug/logging enhancement – Adds or improves logging/debug support

🧪 How Has This Been Tested?

  • Unit Tests (tests/unit/)
  • Functional Tests (tests/functional/)
  • Integration Tests (tests/integration/)
  • Manual Testing

⚙️ Test Configuration:

  • OS: Linux
  • Python: 3.14 locally and in the Docker image
  • Docker build tested
  • python scripts/run_tests.py with TEST_* populated: 101 passed (was 79), 100.00%
    coverage
    (was 99.38%) with no uncovered lines anywhere in app/. The 95% gate holds.
  • The whole suite was re-run against the upgraded dependencies and against httpx2: 101 passed,
    100% coverage, ruff 0.16.7 clean. Warning count dropped 2 → 1 — starlette's httpx deprecation
    is gone, and the one left is an anyio alias inside starlette itself.
  • pre-commit run --all-files: every hook passes. The ruff checks are validated against the version pinned in .pre-commit-config.yaml, which is now the same version pyproject.toml and the lint job resolve — py312 is zero-churn there too.
  • Container built from this branch: one successful /authenticate200 with the full profile and exactly one unauthenticated CSRF prefetch; one wrong-password request → clean 401, one prefetch, no unclosed-client or unretrieved-task warnings.
  • Each of the two behavioural fixes was verified to fail against the previous code, so the new tests are genuine regression pins rather than passing tautologies.

✅ Checklist

  • My code follows the CONTRIBUTING.md guidelines
  • I've performed a self-review of my changes
  • I've added/updated necessary comments and docstrings
  • I've updated relevant docs (README or endpoint docs)
  • No new warnings introduced
  • I've added tests for the fixed behaviour
  • All tests pass locally (scripts/run_tests.py)
  • I've run linting and formatting (pre-commit run --all-files)
  • Docker image builds and runs correctly
  • Changes are backwards compatible

🛠️ Affected API Behaviour

  • app/app.py – Removed the endpoint's redundant CSRF prefetch and the app-level lock
  • app/pesu.py – Client lifecycle and prefetch task management

🐳 DevOps & Config

  • .github/workflows/deploy-staging.yaml – Manual dispatch, working stale-deploy guard
  • .github/workflows/deploy-prod.yaml – Removed a stray git merge --abort
  • pyproject.toml / uv.lock – ruff target-version aligned with requires-python; every
    dependency upgraded, floors raised to match the lockfile, and httpx migrated to httpx2
  • .github/workflows/lint.yaml – one job instead of three identical ones, ruff taken from uv.lock
  • .pre-commit-config.yamlpre-commit autoupdate, which aligns the ruff hook with pyproject.toml

🧠 Additional Notes

Commits are deliberately kept one-per-concern so the correctness fix, the concurrency change, the
workflow change and the doc/config change can each be reverted independently.

One pre-existing issue is deliberately not touched, to keep this reviewable:

  • The function-scoped TestClient fixture in tests/unit/test_app_unit.py enters the real
    lifespan per test, so each test makes a real network call. (The new
    test_authenticate_does_not_trigger_additional_prefetch mocks the singleton and makes none.)

🤖 Generated with Claude Code

https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ

aditeyabaral and others added 4 commits September 12, 2026 00:35
`authenticate()` acquired a client but only closed it as its last
statement, so every raise before that leaked the client and its
connection pool until garbage collection. The most common case is
`AuthenticationError` -- a wrong password, which is routine traffic
rather than an edge case -- alongside `CSRFTokenError` and the
`ProfileFetchError`/`ProfileParseError` raised out of
`get_profile_information()`. The post-login body is now wrapped in
try/finally.

Two more instances of the same bug in the same file:

- `_fetch_new_client_with_csrf_token()` created a client and never closed
  it when the CSRF meta tag was absent or when the initial GET raised.
  It cannot use try/finally, because on success the client *is* the
  return value, so it closes only on the way out via except/raise.
- `_prefetch_client_with_csrf_token()` leaked the freshly fetched client
  if it never reached the cache -- if the old client's `aclose()` raised,
  or if the task was cancelled while waiting for the lock, which can
  happen during shutdown.

Six of the seven new tests fail against the previous code; the seventh
pins the success path, which was already correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ
Two independent prefetch paths fired on every `/authenticate` call, both
introduced together in 13c1475, so this has been the behaviour since
async support landed:

- `PESUAcademy._get_client_with_csrf_token()` spawns a prefetch task
  right after handing out the cached client, and
- the `/authenticate` endpoint queued another via
  `background_tasks.add_task(...)` after the response was sent.

The second closed the client the first had just cached and replaced it,
so each authentication made two `GET https://www.pesuacademy.com/Academy/`
calls and discarded one. Container logs showed the pair plainly: two
fetches, two different tokens, per request. The endpoint's copy is now
gone, halving our upstream request volume.

Two related fixes in the same area:

- The prefetch task was created with a bare `asyncio.create_task()` and
  no reference kept, so it could be garbage collected mid-flight. Tasks
  are now held in a set until they finish.
- A prefetch that raised was never observed -- the exception surfaced
  only as "Task exception was never retrieved" at GC time. The done
  callback now retrieves and logs it. A failed prefetch stays non-fatal:
  the cache is left empty and the next request fetches inline.

`CSRF_TOKEN_REFRESH_LOCK` is removed and `_refresh_csrf_token_with_lock`
renamed to `_refresh_csrf_token`. With the endpoint's path gone its only
caller is the 45-minute refresh loop, which is inherently serial, and
`lifespan()` always bypassed that lock anyway -- so it never provided
mutual exclusion. `PESUAcademy._csrf_lock` still guards every read and
write of the `(client, token)` pair, which is the invariant that matters.
The prefetch continues to fetch outside that lock and take it only for
the swap, so a 10s upstream timeout cannot block incoming requests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ
`deploy-staging.yaml` had a single `workflow_run` trigger, so staging
could not be redeployed on demand. That nearly cost us on 2026-09-12:
the v4.0.0 merge's Pre-Commit Checks failed on one matrix leg, the deploy
correctly skipped, and only the fact that a re-run emits a fresh
`workflow_run` event kept staging updatable without a full production
deploy.

Adding the trigger alone is not enough, and fails silently: on a
dispatch every `github.event.workflow_run.*` expression is empty, so the
old `if` would evaluate false and the job would always skip. The
condition now branches on `github.event_name` -- the automatic path is
unchanged, and the manual path additionally requires
`github.ref_name == 'dev'` so a dispatch cannot ship an arbitrary branch
to staging. The concurrency group is a constant rather than being keyed
on `head_branch`, which would be empty on a dispatch.

Also carried over from pesu-dev#150, which this supersedes:

- A freshness guard that skips the deploy when `dev` has advanced past
  the commit CI validated. pesu-dev#150 implemented this as a `run:` step ending
  in `exit 0`, which succeeds and lets the deploy step run regardless --
  the appearance of protection with none of the effect. It is a step
  output consumed by `if:` here so it actually skips. On a manual
  dispatch there is no validated SHA, so the guard is a deliberate no-op
  that logs the SHA going out.
- Removal of the stray `git merge --abort` in `deploy-prod.yaml`: an
  `--ff-only` merge that fails never creates MERGE_HEAD, so the abort
  only emitted "fatal: There is no merge to abort" into the logs.

The deploy hook call gains `curl -fsS`. Without `-f`, curl exits 0 on an
HTTP 4xx/5xx, so a rejected deploy hook reported "✅ deployment
completed successfully". The same latent issue exists in the three curl
calls in `deploy-prod.yaml`; those are left for a separate change.

`permissions: contents: read` -- pesu-dev#150 needed write only because it
pushed version bumps, which are not part of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ
The `/health` response table described `status` as a `str` when the
endpoint returns a `bool`, typed `timestamp` as `string` where the
`/authenticate` table above uses `datetime`, and described that
timestamp as "the time of authentication" in a health-check table. All
three now match the conventions of the `/authenticate` table. `message`
stays `str`, which was already right.

Separately, ruff's `target-version` was `py314` while `requires-python`
is `>=3.12`, so lint would accept 3.14-only syntax that breaks the 3.12
leg of the CI matrix. Lowered to `py312` to match `requires-python`, the
README, CONTRIBUTING and the 3.12/3.13/3.14 matrix. Verified against the
ruff version pinned in .pre-commit-config.yaml rather than a newer local
one: `ruff check` and `ruff format --check` both pass unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ
aditeyabaral and others added 6 commits September 12, 2026 01:00
Nothing bumps the version automatically, and pesu-dev#150's attempt to do it in
CI cannot work -- it pushes to `dev`, which is protected, and the push is
sequenced before the deploy step, so staging would never deploy. Rather
than give CI a bypass credential, this asks the author instead: a check
that compares `project.version` against the base branch and fails the
pull request if it was not raised.

Reading instead of writing sidesteps branch protection entirely, and it
is also more honest about semver -- no script can tell a patch from a
breaking change, but the person writing the change can.

The check also verifies uv.lock records the same version. Bumping
pyproject.toml without re-running `uv lock` leaves the lockfile stale,
which is a mistake easily made by hand (I nearly made it myself).

Deliberately not using a trigger-level `paths:` filter: a required status
check whose workflow never runs stays pending forever and blocks the pull
request. The job always runs and decides inside itself.

Verified against all four outcomes: an unchanged version fails, a version
that decreases fails, a bumped version with a stale uv.lock fails, and a
bumped version with a matching lockfile passes.

To actually block merges this has to be added to the branch protection
rule for `dev` as a required status check; the workflow alone only
reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ
These are bug fixes and internal changes with no API change, so patch is
the right level. Satisfies the version check added in the previous
commit -- the pull request that introduces that rule should be the first
to follow it, not the first to be exempt from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ
Three related fixes to client handling, all in app/pesu.py.

**Cleanup can no longer mask the original error.** The try/finally added
earlier in this branch closes the request's client on every path, but if
`aclose()` itself raised it would replace the exception the body raised,
turning a routine 401 into a 500 -- a failure mode this branch
introduced, since the old code never closed on the error paths at all.
All four close sites now go through `_close_client_quietly()`, which logs
a warning instead of raising. `CancelledError` is deliberately not
caught, because cancellation still has to propagate.

One behaviour change falls out of that: a cached client which refuses to
close no longer aborts a token refresh. Previously the refresh raised and
left the cache empty; now the failure is logged and the fresh client is
cached as intended. `test_prefetch_closes_new_client_when_caching_fails`
asserted the old outcome and is replaced by two tests -- one for the
broken-old-client case, and one covering the window it was really meant
to protect, a prefetch cancelled while waiting for the lock.

**Shutdown stops in-flight prefetches.** `close_client()` closed the
cached client but left prefetch tasks running, so one could complete
*after* the close and quietly cache a fresh client that nobody would ever
close. It also touched `self._client` without holding `_csrf_lock`,
racing the swap in `_prefetch_client_with_csrf_token`, and left
`_csrf_token` set. Prefetches are now cancelled and awaited first, then
the cached client is closed under the lock and both cache slots cleared.
Cancelling is safe rather than leaky precisely because both prefetch
stages close their own client if interrupted before it reaches the cache.

**A cold cache no longer fetches under the lock.**
`_get_client_with_csrf_token` performed its inline fetch while holding
`_csrf_lock`, so a cold start or any burst outpacing the prefetch queued
every concurrent request behind a 10s upstream timeout. The lock now
covers only the cache read and clear. This costs nothing, because each
caller needs its own client anyway -- they were already fetching one
apiece, just one at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ
`pesu_exception_handler` called `logging.exception()` for every
`PESUAcademyError`, so each wrong password wrote a full traceback at
ERROR level. That is wrong on three counts: a 401 is the API working
correctly rather than a server fault, the noise buries genuine errors and
makes ERROR useless as a signal, and anyone alerting on error rate gets
paged by users typo'ing their passwords. The traceback is identical every
time and carries no diagnostic value.

Severity now follows the status code -- 4xx logs a warning with no
`exc_info`, 5xx keeps the stack trace, since a 502 from the upstream or a
500 from us is genuinely worth a trace. `validation_exception_handler`
had the same problem for 400s and gets the same treatment, and now logs
the validation errors themselves, which are actually useful.

Verified in a container: a wrong password and a malformed request each
produce one WARNING line, and the whole log contains zero tracebacks
where it previously held one per failed login.

Also in this commit, since it lives in the same file: the unit
`TestClient` fixture now patches the prefetch and close, so entering the
real lifespan no longer makes a live request to pesuacademy.com on every
test that uses it. `authenticate` is left unpatched so individual tests
can still patch it themselves. The integration suite's module-scoped
fixture is deliberately left alone -- exercising the real lifespan once
per module is the point of an integration test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ
Both deploy-hook calls in `deploy-prod.yaml` used `curl -X POST` with no
`-f`, so curl exits 0 on an HTTP 4xx/5xx and a *rejected* deploy hook
reported "✅ deployment completed successfully". Now `curl -fsS`, matching
the staging workflow, and reading the hook URL from the job `env` rather
than interpolating the secret into the shell source.

Separately, every workflow pinned `actions/checkout@v4` and
`actions/setup-python@v5`, both of which run on Node 20. CI has started
warning "Node.js 20 is deprecated ... being forced to run on Node.js 24"
on every job, and it will break outright when GitHub drops the shim.
Bumped to `actions/checkout@v5` and `actions/setup-python@v6`.

The lint, pre-commit, docker and version-check workflows all run on pull
requests, so those bumps are exercised by this PR's own CI across
3.12/3.13/3.14. `deploy-prod.yaml` runs only on `workflow_dispatch`, so
its bumps and its curl change can only be confirmed on the next
production deploy. `docker/login-action@v3` is left alone -- it is not in
the deprecation warning and cannot be verified from a PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ
The previous commit made `validation_exception_handler` log
`exc.errors()` so the failure was diagnosable. That was a mistake:
each entry carries an "input" key, and for a *missing required field*
pydantic sets it to the entire request body -- so a request that omits
`username` logged the caller's password in plaintext:

    Request data could not be validated: [{'type': 'missing',
      'loc': ('body', 'username'), 'msg': 'Field required',
      'input': {'password': 'hunter2-actual-secret'}}]

Only `type`, `loc` and `msg` are logged now, which keeps everything that
made the change worthwhile -- which field failed and why -- and none of
the submitted values. The response body was never affected; it is built
from `loc` and `msg` only.

This is distinct from the username in failed-authentication logs, which
is deliberate and stays: tracing a failed login back to an account is
the entire point of that line. A password is never traceability data.

Two tidy-ups found in the same review pass:

- `close_client()` snapshotted `_prefetch_tasks` twice, once to cancel
  and once to await. Correct only because `cancel()` cannot run the done
  callbacks that mutate the set; now snapshotted once so it does not
  depend on that.
- The deploy-hook guard steps in `deploy-prod.yaml` still interpolated
  `${{ secrets... }}` into the shell source while the curl calls beside
  them read from the job `env`. Both now read from `env`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ
@aditeyabaral aditeyabaral changed the title fix: close leaked httpx clients, halve upstream CSRF traffic, and allow manual staging deploys fix: HTTP client leaks, duplicate CSRF fetches, log hygiene and CI gaps Sep 12, 2026
aditeyabaral and others added 5 commits September 12, 2026 12:24
actions/checkout v5 -> v7, actions/setup-python v6 -> v7 and
docker/login-action v3 -> v4. The earlier round bumped only the actions named
in the Node 20 deprecation warning, which is why login-action was left behind
at v3 while everything around it moved; v4 is where it picks up Node 24.

lint.yaml loses its Python matrix. Ruff is a self-contained binary whose
results depend on its own version and on tool.ruff.target-version, not on the
interpreter installed next to it, so the 3.12, 3.13 and 3.14 legs were running
byte-identical work three times. The test suite is where interpreter
differences actually surface, and pre-commit.yaml already matrices that.

It also stops installing ruff unpinned. The repo had three ruff versions at
once -- `pip install ruff` here, v0.12.7 in .pre-commit-config.yaml and
>=0.15.14 in pyproject.toml -- so a ruff release adding a rule could fail a PR
that changed nothing. The job now runs the ruff from uv.lock, and
`pre-commit autoupdate` brings the hook to the same version.

NOTE: collapsing the matrix renames the reported check from
"lint (3.12)" / "lint (3.13)" / "lint (3.14)" to "lint", so dev's required
status check contexts must be updated to match or they will sit permanently
pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
app/app.py:74-75 -- the branch that reports a refresh task which fails its own
cancellation -- was the last uncovered code in the project. The test stubs the
refresh loop with one that turns CancelledError into a RuntimeError, and
asserts both that the failure is logged and that shutdown carries on past it
to close the client.

Coverage is now 100% across app/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
Every caller of _close_client_quietly runs it from an `except BaseException`
handler or a `finally` -- which is exactly where a *second* cancellation can
land, when a shutdown cancels a task that is already unwinding from its first
one. A plain `await client.aclose()` there is abandoned part-way and the
connection pool is never released, so the leak this helper exists to prevent
came back under the one condition the helper is most likely to run in.

The close now runs as its own shielded task: it completes regardless, while
CancelledError still propagates to the caller, so cancellation semantics are
unchanged. A module-level set holds a strong reference, since a close that
outlives its awaiter would otherwise be a bare task and free to be garbage
collected mid-flight.

The new test drives the exact sequence -- cancel while waiting for the lock,
then cancel again once the close is in flight -- and fails against the
previous implementation, where the close never finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
`uv lock --upgrade` across the board, with the pyproject floors raised to the
versions actually locked so they stop understating what the project needs.
Notable: fastapi 0.136.1 -> 0.141.1, starlette 1.0.1 -> 1.6.0, uvicorn
0.47.0 -> 0.52.4, pytest 9.0.3 -> 9.1.1 and ruff 0.15.14 -> 0.16.7. That last
one also settles the three-way ruff disagreement between this file, the
pre-commit hook and the lint workflow.

httpx is the one that is not a plain version bump. The distribution is frozen
at 0.28.1; the project continued under a new name at pydantic/httpx2, still
authored by Tom Christie, and starlette 1.6.0 now imports httpx2 first and
only falls back to httpx with a StarletteDeprecationWarning. So staying put
means staying on an abandoned distribution and on the deprecated path through
starlette's TestClient.

The API surface we use is unchanged -- AsyncClient(follow_redirects, timeout),
get, post, aclose -- so the migration is the import, the annotations and the
29 test patch targets. Production picks up httpcore2 and truststore in place
of httpcore; httpx2-jsfetch resolves into the lockfile but is emscripten-only
and never installs in the image.

Minor rather than patch, since the runtime dependency set changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
astral-sh/setup-uv publishes no floating major tag -- only exact versions like
v10.1.0 -- so `@v10` failed to resolve and the job died before running ruff.
Using the action would mean pinning an exact version and bumping it by hand,
so this installs uv the way pre-commit.yaml already does instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
@aditeyabaral aditeyabaral changed the title fix: HTTP client leaks, duplicate CSRF fetches, log hygiene and CI gaps fix: client leaks, duplicate CSRF fetches, log hygiene, CI gaps and dependency upgrades Sep 12, 2026
The Docker image has shipped `python:3.14-alpine` for both stages while
`requires-python` said `>=3.12`, so CI spent three serial legs validating two
interpreters nobody deploys. The matrix also never produced a signal: in every
failure on record the first leg failed and fail-fast cancelled the other two,
so it only ran to completion when it had nothing to report. And the project is
not on PyPI, so nothing consumes it as a library with its own choice of
interpreter.

Rather than keep testing a promise that was never exercised, the promise is
narrowed to match reality:

- `requires-python` `>=3.12` -> `>=3.14`, with `uv.lock` relocked
- ruff `target-version` back to `py314`, which is now correct rather than
  ahead of the floor -- verified zero-churn, `ruff check` and
  `ruff format --check` both clean
- `pre-commit.yaml` drops its matrix and its now-meaningless `max-parallel`
- `lint.yaml` sets up 3.14 instead of 3.12
- README, CONTRIBUTING, the PR template and the bug-report template all said
  3.12; every one of them now says 3.14

Suite re-run on 3.14 locally: 101 passed, 100.00% coverage, all 12 pre-commit
hooks green.

Version stays at 4.1.0: the HTTP API is unchanged, which is what this
project's version has always tracked (3.0.0 was camelCase keys, 4.0.0 was the
KYCAS removal). This changes runtime requirements only, the same class of
change as the httpx2 migration already in this PR.

NOTE: dropping the matrix renames the checks from
"pre-commit-checks (3.12|3.13|3.14)" to "pre-commit-checks", so dev's required
status check contexts need updating again -- 7 contexts down to 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
@aditeyabaral aditeyabaral changed the title fix: client leaks, duplicate CSRF fetches, log hygiene, CI gaps and dependency upgrades fix!: client leaks, log hygiene, CI gaps, upgraded dependencies and a Python 3.14 floor Sep 12, 2026
aditeyabaral and others added 5 commits September 12, 2026 15:27
…e deploy secrets

Two problems, one root cause. `validate-pr-origin` requires pull requests to
come from a fork, and GitHub withholds secrets from fork pull requests, so
`scripts/run_tests.py` always took its no-secrets branch on a PR. That branch
passed **no `--cov-fail-under` at all**, so every pull request reported green
having enforced nothing about coverage and having skipped all 11 live tests.
The gate and the live tests only ever ran on a push to `dev` -- after merge.

- `scripts/run_tests.py` now applies the coverage gate on both paths. Measured
  before changing it: the no-secrets run is 90 passed / 11 deselected at
  99.16%, so 95% holds with room to spare. It also announces the skip via
  `::warning::` and the step summary, because pre-commit swallows a passing
  hook's output and the fallback was completely invisible.

- `live-tests.yaml` runs the pull request's own code against the real service.
  This needs `pull_request_target`, which is the only way a fork PR can see
  secrets -- and normally a credential-exfiltration vector, since the checked
  out code is untrusted. Two things make it acceptable here:

  1. `environment: live-tests` carries required reviewers, so nothing runs
     until a maintainer approves, and every new push needs approval again.
  2. Only `TEST_*` is injected. A secret reaches the runner solely because
     this file -- which comes from the base branch and cannot be edited by a
     pull request -- puts it there. The worst case of a careless approval is a
     leaked disposable student account, which can be rotated.

  Deliberately not a required status check: it waits on a human, so requiring
  it would block every pull request on reviewer availability.

- Deploy hooks and registry credentials are now scoped to `staging` and
  `production` environments rather than being readable by every workflow in
  the repo. Nothing needs approval there -- staging deploys automatically on a
  dev push -- the environments exist purely to bound the blast radius.

Also declares per-job `permissions` in `deploy-prod.yaml`, which is the
prerequisite for flipping the repo-wide default from write to read.
`sync-dev-to-main` is the one job that genuinely needs `contents: write`, for
its push to main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
The `::warning::` added in eb08fd5 never appeared: pre-commit captures a
hook's output and prints it only on failure, so the annotation was swallowed
by the very mechanism it was meant to work around. Confirmed against the run
on this PR -- zero annotations created.

`verbose: true` on the hook makes pre-commit print it regardless. That also
surfaces the pytest summary line and the coverage total in CI, where both were
previously hidden, so "90 passed, 11 deselected" is now visible rather than
looking identical to a full 101-test run.

Verified both ways: without credentials the annotation, the deselect count and
"Total coverage: 99.16%" all print; with them, 101 passed at 100.00%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
Drops `live-tests.yaml` and its `live-tests` environment. Running a pull
request's own code with credentials needs `pull_request_target`, and the
approval gate only ever made that a judgement call a maintainer has to get
right on every push -- for a benefit that is narrow, since a broken live
integration already fails the `dev` run and so blocks the staging deploy
before it can reach anything.

Kept, because they are independent of the approval gate and address the
problem that was actually biting:

- the coverage gate on the no-secrets path, which every pull request takes
- `verbose: true`, so a partial run is visible rather than looking identical
  to a full one
- `staging` and `production` environments, and the per-job `permissions` in
  deploy-prod.yaml

CONTRIBUTING now says plainly that the live tests do not run on a pull request
and have to be run locally, rather than describing an approval flow that no
longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
Removes the `environment:` declarations from the deploy jobs and deletes the
`staging` and `production` environments. This was the prerequisite I proposed
for the approval-gated live tests, so it goes with them.

The deploy secrets stay repo-level, which is where they already were -- nothing
regresses relative to `dev`. Note the declarations were never protecting
anything on their own: an environment only scopes a secret once the value is
moved into it and the repo-level copy deleted, and that was never done.

`permissions:` blocks are left in place. They bound each job's GITHUB_TOKEN
rather than its secrets, are unrelated to environments, and are the
prerequisite for flipping the repo-wide default from write to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
Removes the five `permissions:` blocks this PR added -- four in
deploy-prod.yaml and one in deploy-staging.yaml. `push-to-ghcr`'s block is
left alone: it already exists on `dev` and predates this PR.

Both files now match `dev` exactly on permissions, so each job's GITHUB_TOKEN
falls back to the repository default (currently `write`), which is what it did
before. Nothing regresses.

The knock-on: flipping `default_workflow_permissions` to read is off the table
until these declarations are made again, since `sync-dev-to-main` needs
`contents: write` for its push to `main` and would break silently without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
@aditeyabaral
aditeyabaral merged commit cb49791 into pesu-dev:dev Sep 12, 2026
5 checks passed
aditeyabaral added a commit to aditeyabaral/auth that referenced this pull request Sep 13, 2026
Found by the metrics added in the previous commit. An idle process, seconds
after boot, reported two `csrf_fetch` calls and two clients created with one
already closed:

    upstream.csrf_fetch.success  2
    httpClients                  {created: 2, closed: 1}

`lifespan` prefetches a client and caches it, then starts the refresh loop --
which refreshed *immediately* on its first iteration, fetching a second token
and discarding the one just prefetched. Every startup paid an extra upstream
round trip for a client it threw away, and on Render, which restarts often,
that is every restart.

The loop now sleeps before its first refresh, which is all it was ever meant to
do: lifespan primes the cache, the loop keeps it fresh afterwards. One fetch,
one client, none discarded.

The same class of waste as the duplicate prefetch fixed in pesu-dev#156, and invisible
for the same reason -- nothing counted the upstream calls. It took about two
minutes for the new metrics to surface it, which is a fair argument for them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
aditeyabaral added a commit that referenced this pull request Sep 13, 2026
…hmark output paths, and six bug fixes (#157)

* fix: import httpx2 in the benchmark utility

The httpx2 migration in #156 removed `httpx` from the project, but
`scripts/benchmark/util.py` still imported it. Every benchmark script imports
`util`, so all three have been dead on `dev` and `main` since that merge:

    ModuleNotFoundError: No module named 'httpx'

Nothing caught it. Ruff does not resolve third-party imports, no test imports
these scripts, and the coverage gate is `--cov=app`. The check I ran after the
migration was a grep over `app/`, `tests/`, `pyproject.toml`, `Dockerfile` and
`README.md` -- which simply omitted `scripts/`. A rename needs a repo-wide
grep, not a directory-by-directory one.

Production was never affected: the Dockerfile copies only `app/`.

The API is identical, so this is the import and the two call sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* feat: add an in-memory metrics collector

Implements #129's collector, with two changes from the sketch in the issue.

Families are typed registry objects rather than free strings. A typo in a
metric name would otherwise create an orphan series that silently never gets
reported; now it raises at the call site, and `FAMILIES` doubles as the single
source of HELP and TYPE text shared by every view.

Dimensions are labels, not name suffixes. The review on #132 asked for keys
like `requests_failed_status_{code}`, but those cannot be rendered as
Prometheus -- each string becomes its own family needing its own HELP and TYPE,
and `sum by (status)` becomes impossible. `responses_total{status="401"}`
aggregates and stays one family however many status codes appear.

No lock. Every mutation is a dict read and write with no await between them, so
the event loop cannot interleave two increments. The asyncio.Lock in
app/pesu.py exists because that code swaps several fields *around* an await,
which is a different problem. The docstring records where this stops holding.

Unlabelled series are seeded at zero so a freshly started process exposes them
before its first request -- otherwise a series springs into existence mid-window
and `rate()` reads it as a spike. `process_start_time_seconds` is exposed for
the same class of reason: Render restarts wipe these counters, and without it a
dashboard cannot tell a restart from a drop in traffic.

Nothing imports this yet. 20 tests, 100% coverage of the new module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* feat: render metrics in the Prometheus text exposition format

This is what unblocks #130: Grafana scrapes Prometheus, not arbitrary JSON, so
a JSON-only /metrics would have needed an exporter written later.

Hand-rolled rather than pulling in prometheus_client, which is the direct
answer to the dependency question raised on #132: **no dependencies are added.**
prometheus_client installs a process-global default registry at import time --
precisely the "global collector outside the entry point" the review rejected --
and it would become a second source of truth beside the snapshot we need anyway
for the pydantic view. What it buys over these 50 lines is histogram buckets,
which we do not expose. The format for counters, gauges and a quantile-less
summary is a small, stable grammar that the tests pin exactly.

Details that are easy to get wrong, so they are tested:
- the media type must carry `version=0.0.4`, or a scraper guesses the format
- label values are double-quoted, so `"`, `\` and newline need escaping; HELP
  text is unquoted, so only `\` and newline do
- labels render sorted, which keeps the payload deterministic and diffable
- whole numbers render without a decimal point, matching every other exporter

16 tests, 100% coverage of the renderer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* feat: add a metrics response model

Answers "create a model for this -- the response model will also need an
update" from the review of #132.

The awkward part is that metric keys are dynamic: the status codes and route
templates that appear depend on traffic, so a fixed-field model cannot express
them. Resolved by noting that dynamic *keys* do not require dynamic *fields* --
`responses_by_status: dict[str, int]` and `requests_by_route:
dict[str, RouteMetricsModel]` validate every value, generate correct OpenAPI
`additionalProperties`, and keep `strict=True` meaningful.

`from_snapshot` casts every value explicitly. The collector stores floats, and
strict mode rejects a float for an int field, so an un-cast value would be a
500 in production rather than a payload. That is the single most likely bug
here, which is why the fresh-collector case is its own test.

No timestamp field, deliberately: `IST` lives in app/app.py and importing it
here would make app.models depend on app.app. `startTimeSeconds` and
`uptimeSeconds` carry the same information, need no timezone, and line up with
the Prometheus gauge.

`averageSeconds` is null rather than absent on a fresh process, so consumers
see one stable shape instead of a key that materialises after the first
request.

11 tests, 100% coverage of the new module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* feat: record request metrics in an HTTP middleware

Implements the middleware layer asked for in the review of #132, rather than
instrumenting each route by hand. Eight things in the review's pseudo-code do
not work as written; the interesting one is the fourth.

**The layering.** starlette/applications.py builds the stack as
ServerErrorMiddleware -> user middleware -> ExceptionMiddleware -> router. Our
`@app.exception_handler(Exception)` becomes ServerErrorMiddleware's handler and
so runs *above* this middleware; the RequestValidationError and
PESUAcademyError handlers live in ExceptionMiddleware, *below* it. That cuts
both ways:

- a handled PESUAcademyError is already a response by the time call_next
  returns, so the `except` branch never sees it -- the pseudo-code assumes it
  does;
- an unhandled exception passes through us but its 500 is rendered above us, so
  we never see that response either, and the `except` branch has to record the
  status itself or requests_total silently stops matching sum(responses_total).

So: the middleware owns status, route and latency; the exception handlers own
the error type, one line each. Different families, so one failed request yields
exactly one status sample and one error sample. That is also what recovers the
information a status code loses -- CSRFTokenError and ProfileFetchError are both
502, and only errors_total tells them apart.

The other corrections: `status < 400` for success, not `< 300`, or /readme's 308
counts every readme hit as a failure; perf_counter rather than time(), since a
wall clock can step backwards and poison a cumulative sum; scope["route"] read
only after call_next, via getattr, because plain Starlette routes never set it;
and latency named for what it measures, which is time to response *start* --
call_next returns at http.response.start and the body streams afterwards.

Cardinality is bounded at both attacker-controlled labels: the route is the
matched template with unmatched paths collapsed to one bucket, and the method is
clamped to the seven known verbs. Keying on the raw path would let a scanner
walking /wp-login.php mint a series per probe.

The profile split is the one deliberate exception to "middleware, not routes".
Reading the body in middleware would consume the downstream receive channel and
pull a plaintext-password payload into another layer; how many auth requests
arrive is already free from route_requests_total, and only the split needs the
body.

Cancellation from a client disconnect is left unrecorded, so total exceeds
success + failed while requests are in flight. Swallowing it to record would be
worse than the undercount.

163 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* feat: expose /metrics and /metrics.json

Two paths rather than content negotiation on one. Real scrapers send
`Accept: application/openmetrics-text;...,text/plain;version=0.0.4;q=0.5,*/*;q=0.1`,
curl sends `*/*`, and a browser sends `text/html,...,*/*;q=0.8` -- none of which
unambiguously mean JSON, so any resolution of the wildcard surprises half the
callers, and getting there needs a hand-rolled q-value parser larger than the
renderer. A single path also cannot carry a response_model, so Swagger would
show either a misleading schema or none.

Split in two, each is documented properly: /metrics declares
response_class=PlainTextResponse with a text/plain example, mirroring how
app/docs/readme.py documents its text/html body, and /metrics.json declares
response_model=MetricsModel -- the second response_model in the codebase after
/authenticate, which is what "the response model will also need an update"
asked for. JSON deliberately does not live on /metrics: Prometheus has
effectively reserved that path.

/metrics.json returns the model rather than a JSONResponse, so FastAPI
serializes it with by_alias=True and the camelCase keys come for free, with no
hand-patched dict of the kind /authenticate needs for its datetime.

Both reuse the existing "Monitoring" OpenAPI tag.

The most valuable of the 13 new tests is the unhandled-exception one. That path
runs through ServerErrorMiddleware, which sits *above* our middleware, so
whether a 500 is recorded at all cannot be established by reading the code --
only by driving a real exception through the whole stack. There is also a test
asserting the accounting invariants hold end to end:
sum(responsesByStatus) == success + failed, and sum(errorsByType) < failed
whenever a 404 is in the mix, since the router's 404 runs no handler of ours.

176 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* refactor: give the benchmark scripts a shared output path helper

Closes #123. Implements the five changes requested on #143, plus the review's
"please incorporate similar changes in unauthenticated_csrf_token_expiry.py".

Every output path was a bare relative filename, so results landed wherever the
script happened to be run from -- in practice cluttering scripts/benchmark/ --
and `analyze_benchmark.py` overwrote distribution.png and timeline.png on every
run. `resolve_output_path` in util.py now handles all of it: explicit --output
wins, otherwise `{script}_{date}_{time}[_{tag}].{ext}`, with parent directories
created either way. The default directory is anchored to the repository root
rather than the cwd, so output lands in one place regardless of where the
script is invoked.

util.py is reused rather than adding a module, since both runners already
import it; analyze_benchmark.py now imports it too.

Also in scope, because they are in the files being rewritten:

- `make_request` ended in an unconditional `response.json()`, so `--route
  readme` (a 308 to GitHub returning HTML) crashed the sequential runner and
  was silently swallowed as a *failed request* by the parallel one, skewing the
  very numbers being measured. This is what #132's author was patching when a
  reviewer asked "why is this being added?" -- it is a real bug. Non-JSON
  responses now fall back to the status and the raw text.
- `analyze_benchmark.py --files` was not required, so omitting it raised a bare
  TypeError from a list comprehension instead of an argparse error.
- `unauthenticated_csrf_token_expiry.py` wrote its CSV only after the loop
  ended, and that loop sleeps for hours between requests -- so a Ctrl-C threw
  away every measurement taken. Rows are now written as they are measured.
- Both runners carried a no-op string expression where a docstring cannot go,
  inside `if __name__ == "__main__":`.
- `--route` gained `metrics` and `metrics.json`, now that those exist.

Verified against a live server: default naming, --tag, --output-dir and an
explicit --output all land where intended, and `--route readme` and
`--route metrics` both succeed where they previously could not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* docs: document the metrics endpoints and benchmark output

README gains rows for /metrics and /metrics.json in the endpoint table and a
section for each, following the shape of the /health section. The notes worth
having in writing rather than only in code comments: counters reset on restart
(which is what process_start_time_seconds is for), status codes and exception
classes are recorded separately so CSRFTokenError and ProfileFetchError stay
distinguishable despite both being 502, scrapes of /metrics count themselves on
purpose, and requests.total can briefly exceed success + failed because arrival
and outcome are recorded at different moments.

CONTRIBUTING gains a note on where the benchmark scripts now write, since the
answer changed in this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* chore: bump version to 4.2.0

Minor: new functionality that keeps existing APIs working, per the guidance in
.github/scripts/check_version_bump.py. Two new endpoints, no change to
/authenticate, /health or /readme, and no new dependencies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* refactor: serve both metric formats from one /metrics endpoint

Replaces /metrics + /metrics.json with a single path selecting the
representation by query parameter: `?fmt=prometheus` (the default) or
`?fmt=json`.

`fmt` is a StrEnum, so FastAPI validates it, Swagger renders a dropdown, and an
unrecognised value goes through the existing validation handler -- a 400 with
the usual body, which is itself counted like any other failed request rather
than being a special case.

The default stays Prometheus: a scraper pointed at this path with no query
string must get the exposition format.

`response_model` is None because the response type depends on the parameter and
cannot be declared once. Both shapes are documented under `responses=` instead,
which is what Swagger renders from anyway, so the endpoint documents itself as
well as the two-path version did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* feat: instrument every path in the app

Audited each request, response, error and background task, and closed the gaps.
The set went from 9 metric families to 23.

**The upstream was entirely uninstrumented, which was the biggest hole.** PESU
Academy is the only dependency this service has and the only thing that can be
slow or down, yet nothing measured it. Every call now goes through one helper
that records count, outcome, latency and the upstream status code, labelled by
operation: `csrf_fetch`, `login`, `profile_fetch`. When a request is slow, this
is what says whether it is us or them. It also distinguishes "the call failed"
from "the call succeeded and we could not parse what came back" -- a missing
CSRF tag counts as a successful 200, because it is our parsing that failed.

**Failures now say whose fault they are.** `failures_total{fault}` splits 4xx
from 5xx, so an alert can fire on "our fault" without enumerating statuses.

**Authentication says why it failed, not just that it did.**
`authentication_results_total{result}` records success, invalid_credentials,
csrf_token_error, profile_fetch_error, profile_parse_error and internal_error.
Keyed on the exception class, because CSRFTokenError and ProfileFetchError are
both 502 and mean entirely different things.

**Validation errors say which field.** Bounded by a known-field set, since the
request body is caller-controlled and an open label would be a cardinality hole.

**Profile parsing says what broke**: key_missing, value_missing, unknown_field,
page_structure, no_data, unknown_campus_code. The last one previously only
emitted a warning and raised nothing -- an unknown campus code means the PRN
format changed, which nothing else would have surfaced.

**The internal machinery is visible**: CSRF cache hit/miss (which is the whole
point of the prefetch, and previously invisible), prefetch task outcomes,
background refresh outcomes, and client lifecycle events where created minus
closed is what is still open -- the leak indicator for the bug class this
module spent a release learning to avoid.

Plus `requests_in_flight`, which explains the one gap in the accounting:
total exceeds success + failed by exactly what is still being served.

PESUAcademy now takes a collector, defaulting to a private one so a bare
PESUAcademy() still works. The singleton is still created only in app/app.py.

202 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* fix: stop the CSRF refresh loop fetching a second token at startup

Found by the metrics added in the previous commit. An idle process, seconds
after boot, reported two `csrf_fetch` calls and two clients created with one
already closed:

    upstream.csrf_fetch.success  2
    httpClients                  {created: 2, closed: 1}

`lifespan` prefetches a client and caches it, then starts the refresh loop --
which refreshed *immediately* on its first iteration, fetching a second token
and discarding the one just prefetched. Every startup paid an extra upstream
round trip for a client it threw away, and on Render, which restarts often,
that is every restart.

The loop now sleeps before its first refresh, which is all it was ever meant to
do: lifespan primes the cache, the loop keeps it fresh afterwards. One fetch,
one client, none discarded.

The same class of waste as the duplicate prefetch fixed in #156, and invisible
for the same reason -- nothing counted the upstream calls. It took about two
minutes for the new metrics to surface it, which is a fair argument for them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* docs: describe the full metric set, and bump to 4.3.0

README gains a table of what is measured, grouped by area, and a note on the
two most useful entries: `upstream`, because it is the only dependency this
service has and its latency is measured separately from the API's own, so a
slow request can be attributed rather than guessed at; and `httpClients`,
where created minus closed is the leak indicator and should sit at one at rest.

Minor: new functionality, existing APIs unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* fix: three defects found in review, and complete the documentation

## Defects

**`requests_in_flight` leaked on every client disconnect.** The decrement sat
in the two branches of the middleware rather than in a `finally`, and
`except Exception` does not catch `CancelledError` -- so an abandoned request
incremented the gauge and never decremented it. On a real server it would have
climbed forever, and it is the number documented as explaining the gap between
`total` and `success + failed`, so it would have actively misled. The
cancellation test asserted total, success and failed but not the gauge, which is
why it passed; it now asserts the gauge and fails against the old code.

**A label could shadow a positional parameter.** `increment(family, value=...)`
and `observe(family, seconds=...)` took their amount as an ordinary parameter,
so a family declaring a label named `value` or `seconds` would have had it
silently captured as the amount. Both are positional-only now, which
immediately caught a test relying on exactly that ambiguity.

**A cancelled upstream call was counted as an upstream error.** A disconnect or
a shutdown is not PESU Academy failing. It has its own outcome now, so the error
rate does not spike on every deploy -- precisely when someone is looking.

Also closes the last uninstrumented branch: `profile_field_filtering_total`
records whether a caller's field list actually narrowed the response, measured
at the branch rather than from the request body, so a caller passing exactly the
default list counts as no filtering.

`app/metrics/__init__.py` drops its re-export list, which had to be edited every
time a family was added; modules are imported directly, as `app.exceptions`
already does.

## Documentation

**Swagger documented a response that cannot happen.** FastAPI adds a 422
carrying its own `HTTPValidationError` body to every route whose parameters can
fail validation -- but this API converts every `RequestValidationError` into a
**400** with the same `{status, message, timestamp}` body as every other error.
`/metrics` advertised a status it never returns, in a shape it never emits. The
schema is now built through an override that drops those, matched on their
schema so `/authenticate`'s real 422 (a profile parse failure, using this API's
own response model) is kept. `HTTPValidationError` and `ValidationError` go with
them.

Every response on every route now carries both an example and a schema, which
the `/readme` 308 and the `/metrics` text/plain body previously lacked. The
`/metrics` examples were hand-written and had drifted; both are generated from a
real snapshot now, so the JSON example is complete and round-trips through
`MetricsModel`.

`tests/unit/test_openapi_docs.py` makes the documentation self-checking: every
route documents a success and a 500, every response has an example and a schema,
every JSON example validates against the model it claims, the request examples
cover all three username forms plus profile and field filtering, and the
documented 400, 401 and 200 bodies are compared against real responses. It found
both defects above. Validation runs in JSON mode rather than Python mode
deliberately -- the models are strict, and strict Python-mode rejects the ISO
*string* these responses carry in `timestamp`; JSON mode is the mode a caller
parsing the body is in.

The two test-only exception routes are now `include_in_schema=False`; they were
appearing in the published schema whenever their module was imported.

**README** gains a full metrics reference: how collection works and which of the
three layers records what, the two accounting identities that hold at all times,
the definitions that are easy to assume wrongly (latency is time to response
*start*; success is below 400, not 300; summaries expose sum and count, not
quantiles; scrapes count themselves), a table explaining every metric, and
complete generated examples of both formats.

232 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* fix: let ResponseModel parse the responses it describes

`timestamp` is declared `datetime` under a model-wide `strict=True`, but the API
serializes an ISO string onto the wire. So the published model could not
validate a real response: a client holding a decoded dict -- which is what every
HTTP library hands back -- got a ValidationError from the schema the API
publishes for exactly that purpose.

It also made the documentation tests weaker than they looked. They validated
examples in JSON mode, where pydantic accepts a string for a datetime because
JSON has no datetime type. That passed, but it was working around the problem
rather than finding it.

`strict=False` on that one field accepts both the datetime the API builds with
and the ISO string it returns. Every other field stays strict, which a test now
pins.

The documentation tests assert **both** modes rather than whichever passes, and
a new test round-trips a real `/health` response through the model both ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* refactor: record only the outcome of an authentication, not the reason twice

`authentication_results_total` carried six values -- `success`,
`internal_error`, and one per failure class -- four of which restated what
`errors_total{type}` already recorded about the same event.

Three of those four were not even a different vocabulary, just the class name in
snake_case: `CSRFTokenError` -> `csrf_token_error`, `ProfileFetchError` ->
`profile_fetch_error`, `ProfileParseError` -> `profile_parse_error`. Only
`AuthenticationError` -> `invalid_credentials` said anything the class name did
not, and the audience for these metrics knows the class names.

Worse than redundant, it was a drift risk. The mapping was a hand-written dict
read through `.get(type(exc), "other")`, so adding a fifth exception class and
forgetting the entry would have left two counters disagreeing about one event --
with the less informative one failing silently, which is the failure mode this
PR removes everywhere else.

Now `success` or `failure`, and the reason lives in exactly one place. The
family survives at all because it answers the one question nothing else can: the
login success rate, with success and failure in one family sharing a
denominator. Computing that from `errorsByType` would mean subtracting several
error classes from a different family -- the fragile cross-family arithmetic
this avoids. A test pins `sum(authenticationResults) == authentication.total`.

`internal_error` goes with it: a non-PESUAcademyError escaping `authenticate()`
is now `failure`, and `errors_total` still names the class, so nothing became
unobservable.

app/app.py loses the mapping dict and four imports that existed only to feed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* fix: correct the version bump, and three things found re-reading the diff

**The version bumped twice in one pull request.** 4.1.0 -> 4.2.0 -> 4.3.0,
because the second batch of work read as another feature. One merge to dev is
one bump, so this is 4.2.0; 4.3.0 would have skipped a version that never
reaches dev.

The guidance in `check_version_bump.py` is what led there. It described picking
a level by change type -- "minor: new functionality", "patch: a bug fix" --
which invites exactly that reading when a PR contains several kinds of change.
It now states the rule in force: raise the minor by one, once per pull request,
with major reserved for a backwards-incompatible change.

**The OpenAPI override restated FastAPI's argument list.** It called
`get_openapi()` with five arguments where FastAPI passes fourteen. Every one
missing is None or a default today, so nothing was visibly wrong -- but setting
`servers=` or `license_info=` on the app later would have silently vanished from
the schema. It now captures and delegates to FastAPI's own builder, so it
inherits whatever that grows.

**`_upstream_call` recorded in three places.** Latency and outcome were written
once per branch. A `finally` with a pessimistic default makes "every call is
counted and timed exactly once" structural rather than three copies that have to
stay in step -- and anything escaping without setting the outcome is an error,
which is the right thing to fail to.

**A test still used a label value the app can no longer emit**
(`result="invalid_credentials"`). It passed because the collector validates label
names, not values, so it was quietly asserting a behaviour that no longer exists.

Also guards `FAMILIES` against the drift that made the last two findings
possible: a family defined but left out of the registry would be collected into
and never exposed, silently. Tests now assert the registry matches the module,
that names are unique and valid Prometheus identifiers, that no counter name
collides with a summary's `_sum`/`_count` series, and that every HELP line fits
the exposition example's line limit.

240 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant