Hardening B/2 (stacked on #290): doc cache correctness, 429 instead of 500, health check, CI runs tests again - #291
Merged
Merged
Conversation
gregv
force-pushed
the
hardening/b-reliability
branch
2 times, most recently
from
September 30, 2026 19:18
3a09b48 to
c9ffe99
Compare
…le dicts; judges see team members again common/utils/firestore_helpers.py::doc_to_json was @cached with a key of the doc id alone (1h TTL, 2000 entries, all collections) and handed the SAME dict to every caller. Verified consequences: get_team passed a fresh snapshot but got the cached dict (team pages stale up to 1h in the other gunicorn worker); get_single_hackathon_event popped project_story / replaced users[] on the cached team dicts, so GET /team/<id> in that worker lost the story; _enrich_teams_users_batch zeroed users[] on already-enriched teams; judging iterated enriched dict users as ids -> get_user_by_id(dict) threw -> no members. Now: a DocumentSnapshot is converted directly and never cached (the read already happened); a DocumentReference goes through _doc_to_json_cached (10 min); every dict/list returned is a fresh copy (_copy_json_like — not deepcopy, DocumentReference leaves hold the client). doc_to_json keeps its cachetools API (cache_clear etc.) for clear_all_caches() and the existing test. get_single_hackathon_id / get_single_npo / get_npo_by_hackathon_id pass snapshots so their own TTLs govern. _enrich_teams_users_batch preserves dict users. judging_service resolves u['id'] for dict users and still re-fetches (the member shape needs email) — judging process untouched. Tests: test/common/utils/test_firestore_helpers_cache.py, api/messages/tests/test_enrich_teams_batch.py, api/judging/tests/test_team_members_shape.py. Corrects the CLAUDE.md claim that the cache was keyed by snapshot update_time. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…longer count against get_npo_list ratelimit @limits counters are per-process and all-clients-combined and RateLimitException had no handler, so one crawler burst turned a public endpoint into a 500 for every user. api/exception_views.py maps it to 429 {"error":"rate_limited"} with Retry-After (period_remaining, min 60). services/nonprofits_service.py::get_npo_list had @limits(20/min) ABOVE @cached, so cache hits counted: 20 requests/min to /api/messages/npos (client-fetched by /nonprofits) 500'd everyone. @cached is outermost now. get_problem_statement_list_old (uncached full scan) goes 100 -> 600/min; ISR + getStaticPaths bursts had tripped it. Views wrapped in a blanket except Exception still convert the exception to their own 500 — documented follow-up. Test: api/messages/tests/test_ratelimit_handling.py. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Zero-side-effect liveness route (no Firestore/Slack/PropelAuth imports) and a [[http_service.checks]] block (30s grace, 30s interval, 5s timeout) so Fly can tell a wedged machine from a healthy one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tests job copied .env.example and ran nothing (pytest was commented out), so deploy's needs: [lint, tests] gated on nothing. It now generates a throwaway service-account JSON with openssl into FIREBASE_CERT_CONFIG at job time (common/utils/firebase.py builds the Certificate at import, so .env.example's placeholder blocks collection), runs with EMPTY Slack/Resend/OpenAI/PropelAuth vars, and loops over the verified green set (554 tests) with api/messages/tests one file at a time. Excluded, with reasons in the workflow: contact, github, slack, leaderboard, certificates. Python 3.9 -> 3.10 to match the Dockerfile. pylint scope unchanged (widening it has 62 pre-existing findings). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…built at import First CI run failed in api/messages/tests/test_hackathon_requests.py at fixture setup: common/utils/openai_api.py (via news_service) constructs OpenAI(api_key=...) at import and the SDK raises on an empty key. Locally .env supplied a placeholder, which is why the loop was green there. Co-Authored-By: Claude Fable 5.1 <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.
Stacked on #290 (
hardening/a-security). Base is that branch, so this shows only the reliability changes; merge #290 first, then this. Second half of the backend hardening plan (frontend-ohack.dev/docs/plans/hardening-security-seo-reliability-2026-09.md, sections 3.1–3.4). Test-first throughout; each new test was seen failing for the documented reason before the fix.What changed
develop)doc_to_jsoncache served stale, shared, mutable dicts. Keyed by doc id only (1h, all collections) and returned the SAME object to every caller.get_teampassed a fresh snapshot but got the cached dict → team pages stale up to 1h in the other gunicorn worker.get_single_hackathon_eventpoppedproject_storyand replacedusers[]on the cached team dicts →GET /team/<id>in that worker lost the story._enrich_teams_users_batchzeroedusers[]on already-enriched teams. (The merged CLAUDE.md sentence saying the cache was keyed byupdate_timewas wrong.)_copy_json_like, neverdeepcopy);doc_to_json.cache_clear()API kept.get_single_hackathon_id/get_single_npo/get_npo_by_hackathon_idpass snapshots._enrich_teams_users_batchpreserves dict users. CLAUDE.md corrected.judging_serviceiterated enriched dict users as ids →get_user_by_id(dict)threw →members=[].u["id"]for dict users and still re-fetch (the member shape needs email). Judging DATA only — rubric, rounds, scoring untouched.ratelimitcounters are per-process, all clients combined;RateLimitExceptionhad no handler.get_npo_listhad@limits(20/min)ABOVE@cached, so cache HITS counted — 20 requests/min to/api/messages/npos(client-fetched by/nonprofits) 500'd every user.{"error":"rate_limited"}+Retry-After;@cachedoutermost onget_npo_list;get_problem_statement_list_old100 → 600/min. Views with a blanketexcept Exception(volunteers/planning/store) still convert it to their own 500 — noted follow-up.GET /api/health(zero imports beyond Flask) +[[http_service.checks]]infly.tomltestsjob copied.env.exampleand stopped (pytest commented out), sodeploy: needs [lint, tests]gated on nothing..env.example's placeholderFIREBASE_CERT_CONFIGalso blocks collection of nearly every suite (the Certificate is built at import).opensslat runtime (never committed), runs with EMPTY Slack/Resend/OpenAI/PropelAuth vars, and loops over the verified green set — 554 tests — withapi/messages/testsone file at a time. Excluded with reasons in the workflow: contact (real signature bug + recaptcha env), github/slack (init_authat import), leaderboard (2 known), certificates (network hang). Python 3.9 → 3.10 (matches the Dockerfile).New tests:
test/common/utils/test_firestore_helpers_cache.py(copy isolation, snapshot-beats-cache, reference leaf preserved,cache_clearcontract),api/messages/tests/test_enrich_teams_batch.py,api/judging/tests/test_team_members_shape.py,api/messages/tests/test_ratelimit_handling.py(429 +Retry-After; 30get_npo_listcalls → one read, no exception),api/health/tests/test_health.py.Deploy notes
get_single_npo/get_npo_by_hackathon_idno longer ride the 1h reference cache (they read the doc each call; both are low-volume and nonprofit edits now show immediately). Event-payload nonprofit/team fan-out still uses the 10-min reference cache.main, the Fly health check starts probing/api/healthevery 30s.Test plan
/hack/<event>/manageteam, then open/hack/<event>/team/<id>and reload ~6 times (both workers): the new story shows every time, and it is still present after loading the event page/hack/<event>(which used to strip it from the shared cache)./judge/<event>/team/<id>: team members are listed (was empty).for i in $(seq 1 31); do curl -s -o /dev/null -w '%{http_code}\n' https://<staging>/api/messages/npos; done→ all 200 (was 500 from request 21).GET /api/messages/problem_statements) → 429 withRetry-After, never 500.curl -s https://<staging>/api/health→{"status":"ok"}; Fly dashboard shows the check passing.testsjob runs ~554 tests and is green; break a test in a scratch commit → the job goes red anddeployis blocked.🤖 Generated with Claude Code