fix!: client leaks, log hygiene, CI gaps, upgraded dependencies and a Python 3.14 floor - #156
Merged
aditeyabaral merged 21 commits intoSep 12, 2026
Conversation
`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
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
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
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
…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
27 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📌 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.
httpx.AsyncClientleaked on every failed authentication43ddb7d/authenticatemade two upstream CSRF fetches and discarded one40b6107f569aed/health'sstatusasstr; ruff targeted py314 againstrequires-python >=3.126204d6b5e79b19d4fe4e615458c3ebf70df1a39d8200aa9caapp/app.py:74-75was the only uncovered code left in the projectca68745docker/login-actionsat on v3 while everything around it moved;lintran three identical jobs; three different ruff versions were in use at onced10dcdb,851dd89httpxis abandoned at 0.28.1, and starlette 1.6.0 deprecates it0614f2frequires-pythonpromised>=3.12while the image shipped 3.14 only, so CI validated two interpreters nobody deploysa31df98eb08fd5,18c101a1. 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_tokendeliberately usesexcept/raiserather thantry/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 viabackground_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_LOCKis also gone —lifespan()always bypassed it, so it never provided mutual exclusion;PESUAcademy._csrf_lockstill 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_taskcan 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_dispatchalone would have failed silently: on a dispatch everygithub.event.workflow_run.*expression is empty, so the oldifevaluates false and the job always skips. The condition now branches ongithub.event_name, and the manual path additionally requiresgithub.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 thegit merge --abortremoval indeploy-prod.yaml.#150's auto-version-bump is deliberately dropped, because it cannot work as written. It
git pushes bump commits todev, butdevis protected (required_approving_review_count: 1,enforce_admins: false), so thegithub-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 itssync-minor-to-devjob. Making it work would need an admin bypass credential in CI, which isn't a good trade for automatic version bumping.scripts/bump_version.pyis not carried over.Two bugs found in #150 while folding it in, both fixed here rather than inherited:
run:step ending inexit 0succeeds 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 byif:here, so it actually skips.Also fixed in the step being rewritten:
curl -X POSTwithout-fexits 0 on HTTP 4xx/5xx, so a rejected deploy hook reported "✅ deployment completed successfully". Nowcurl -fsS. The same latent false-green exists in the three curl calls indeploy-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.yamlcomparesproject.versionagainst 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.lockrecords the same version, since bumpingpyproject.tomlwithout re-runninguv lockleaves 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-bumpedhas since beenadded 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 newtry/finallyclosed the client on every path, but ifaclose()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 (CancelledErrorstill 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_lockwith both cache slots cleared. And_get_client_with_csrf_tokenno longer holds_csrf_lockacross 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 withoutexc_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 unitTestClientfixture no longer makes a live network call per test (the integration one still does, deliberately).ebf70df— CI. Both prod deploy-hookcurls lacked-f, so a rejected hook reported "✅ deployment completed successfully". And every workflow ran on deprecated Node 20; bumped toactions/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.pyat 100%. One existing test was replaced rather than kept:test_prefetch_closes_new_client_when_caching_failsasserted 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 reviewWorth calling out plainly.
15458c3madevalidation_exception_handlerlogexc.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 omittingusernamelogged the caller's password:Now only
type,locandmsgare logged — all the diagnostic value, none of the submitted values. The response body was never affected (it is built fromlocandmsgonly). 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_quietlyruns it from anexcept BaseExceptionhandler or afinally, which isexactly 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 connectionpool 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
CancelledErrorstill propagates, socancellation 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 taskthat fails its own cancellation. The test stubs the loop with one that turns
CancelledErrorinto aRuntimeError, and asserts both that it is logged and that shutdown carries on past it to close theclient.
d10dcdb+851dd89— CI.docker/login-actionv3 → v4,actions/checkoutv5 → v7,actions/setup-pythonv6 → v7. The earlier round bumped only what the Node 20 deprecationwarning named, which is why login-action was left behind and the others stopped one major short.
lint.yamlalso loses its Python matrix. Ruff is a self-contained binary whose results depend onits 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'smatrix went too — see the fourth round below.)
Underneath that: the repo had three ruff versions in play at once — an unpinned
pip install ruffinlint.yaml,v0.12.7in.pre-commit-config.yaml, and>=0.15.14inpyproject.toml— so a ruff release adding a rule could fail a PR that changed nothing. The job nowruns the ruff from
uv.lock, andpre-commit autoupdatebrings 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 havealready 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 cannotproduce 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 --upgradeacross theboard, with the
pyproject.tomlfloors raised to what is actually locked so they stop understatingwhat 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.
httpxis the one that is not a plain version bump. The distribution is frozen at 0.28.1; theproject continued under a new name at pydantic/httpx2, still
authored by Tom Christie, and starlette 1.6.0 imports
httpx2first and only falls back tohttpxwith a
StarletteDeprecationWarning. Staying put means staying on an abandoned distribution andon 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. Noapplication logic changed. Production picks up
httpcore2andtruststorein place ofhttpcore;httpx2-jsfetchresolves 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-pythonsaid>=3.12while the Dockerfile has always shippedpython:3.14-alpineonboth 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-fastcancelled the other two. Not once did aleg fail while another passed. Combined with
max-parallel: 1, the matrix only ever ran tocompletion 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.tomlrequires-python>=3.12→>=3.14; rufftarget-versionback topy314uv.lock>=3.14pre-commit.yamlmax-paralleldropped; single 3.14 joblint.yamlREADME.md,CONTRIBUTING.md, PR template, bug-report template3.12reference →3.14target-version = "py314"reverses part of6204d6bin this same PR, which lowered it topy312to match the old floor — it is correct at
py314again now, and verified zero-churn (ruff checkand
ruff format --checkboth clean).uvrebuilt the local environment to 3.14.4 when the floor changed, so the suite genuinely ranon 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-originrequires pull requests to come from a fork, and GitHub withholds secrets fromfork pull requests. So
scripts/run_tests.pyalways took its no-secrets branch on a pull request —and that branch passed no
--cov-fail-underat all. Every pull request reported green havingenforced 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: theno-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::wasswallowed by the very mechanism it was meant to work around — confirmed against this PR's own run,
which created zero annotations.
verbose: trueon the hook fixes it, and as a side effect thepytest summary and coverage total are now visible in CI at all, where they were previously hidden.
90 passed, 11 deselectedno longer looks identical to a full 101-test run. Verified on this PR:one
warning: Live tests skippedannotation, andTotal 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_targetjob was built and then reverted in247b803: it is the onlymechanism 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
devrun, which blocksthe staging deploy before it reaches anything.
CONTRIBUTING.mdnow says plainly that the livetests 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_targetjob for the live tests (247b803), thestagingandproductionenvironments it depended on (956c8b8), and the per-jobpermissionsdeclarationsthat went with them (
8c6df63). The deploy workflows now matchdevexactly on permissions andenvironments. What survives from this round is the coverage gate and the visibility fix above,
which stand on their own.
🧱 Type of Change
(No API or schema change. Breaking for a native install only:
requires-pythonrises to>=3.14andhttpxbecomeshttpx2. The deployed image is unaffected — it already ran 3.14.)🧪 How Has This Been Tested?
tests/unit/)tests/functional/)tests/integration/)python scripts/run_tests.pywithTEST_*populated: 101 passed (was 79), 100.00%coverage (was 99.38%) with no uncovered lines anywhere in
app/. The 95% gate holds.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 versionpyproject.tomland the lint job resolve —py312is zero-churn there too./authenticate→200with the full profile and exactly one unauthenticated CSRF prefetch; one wrong-password request → clean401, one prefetch, no unclosed-client or unretrieved-task warnings.✅ Checklist
scripts/run_tests.py)pre-commit run --all-files)🛠️ Affected API Behaviour
app/app.py– Removed the endpoint's redundant CSRF prefetch and the app-level lockapp/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 straygit merge --abortpyproject.toml/uv.lock– rufftarget-versionaligned withrequires-python; everydependency upgraded, floors raised to match the lockfile, and
httpxmigrated tohttpx2.github/workflows/lint.yaml– one job instead of three identical ones, ruff taken fromuv.lock.pre-commit-config.yaml–pre-commit autoupdate, which aligns the ruff hook withpyproject.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:
TestClientfixture intests/unit/test_app_unit.pyenters the reallifespanper test, so each test makes a real network call. (The newtest_authenticate_does_not_trigger_additional_prefetchmocks the singleton and makes none.)🤖 Generated with Claude Code
https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ