refactor: consolidate rate-limit factories, fix three latent bugs - #387
Closed
lfiaschi wants to merge 1 commit into
Closed
refactor: consolidate rate-limit factories, fix three latent bugs#387lfiaschi wants to merge 1 commit into
lfiaschi wants to merge 1 commit into
Conversation
Outcome of a deep codebase review focused on high-leverage, low-risk cleanups. Four issues, each with regression tests. **1. Rate-limit factory duplication (DRY).** Nine near-identical `_enforce_*_rate_limit` functions across three route modules each did the same `hasattr(state, ...) -> RateLimiter -> stash on state` dance. Centralise that into `rate_limit.get_or_create_limiter(...)` and drive the seven publish/list/resolve/etc. enforcers in `registry_routes.py` from a config table, reducing ~80 lines of boilerplate to a single table. **2. RateLimiter stale-IP purge rarely fired.** The purge was guarded by `sum(len(v) for v in self._requests.values()) % 100 == 0`. Because each call prunes expired timestamps for its own key, the total stays small (typically 1 per active IP after the per-call prune) and the modulo only hits zero by coincidence. Under a single hot IP it never fires. Switch to a per-instance call counter that triggers a purge every 1024 requests regardless of traffic shape — bounding memory deterministically. **3. Non-UTF-8 source files in publish surfaced as HTTP 500.** `extract_for_evaluation` called `.decode()` (strict UTF-8) on every scannable file. A `.py` entry containing arbitrary bytes therefore raised `UnicodeDecodeError`, which `/v1/publish` doesn't catch — it only handles `(ValueError, zipfile.BadZipFile)`. Decode source files with `errors="replace"` so the gauntlet still sees a string, and flag the file in `unscanned_files` so the partial-coverage check downgrades the grade. Keep strict UTF-8 for `SKILL.md` and lockfiles, raising a clean `ValueError` (→ 422) when those are malformed. **4. `seo_routes.sitemap_xml` cached a Starlette `Response` object.** Response objects carry mutable per-request state (headers, charset finalisation when first sent) and are not safe to share across requests. Split rendering into `_build_sitemap_xml()` and cache only the XML string; build a fresh Response on every hit. Tests added in `test_rate_limit.py` (stale-IP purge under a single hot IP and across idle IPs; `get_or_create_limiter` identity/distinctness), `test_publish.py` (non-UTF-8 source files, SKILL.md, and lockfiles), and a new `test_seo_routes.py` (cache holds a string, cache hit skips the DB, ttl=0 disables caching, root pages present in body). 945 server tests pass (up from 933), ruff and mypy clean. https://claude.ai/code/session_016diHyWN9knNJAKe5VwmezW
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.
Summary
Outcome of a deep codebase review focused on high-leverage, low-risk cleanups. Four issues, each landed with regression tests. +371 / -123 LOC, 945 server tests pass (up from 933), ruff and mypy clean.
Findings & fixes
1. Rate-limit factory duplication — DRY (code-health, M impact / S effort)
Nine near-identical
_enforce_*_rate_limitfunctions across three route modules each did the samehasattr(state, ...) -> RateLimiter -> stash on statedance:Fix: Centralise the lazy-init dance in
rate_limit.get_or_create_limiter(app_state, attr_name, *, max_requests, window_seconds) -> RateLimiter. Drive the sevenregistry_routesenforcers from a_RATE_LIMIT_CONFIGStable via_make_enforcer()— each generated function keeps a unique__name__so FastAPI's dependency cache still treats them as distinct. The other two enforcers (search,auth) collapse to three lines apiece.2. RateLimiter stale-IP purge rarely fires — bug + perf (M / S)
server/src/decision_hub/api/rate_limit.py:57previously read:Because each call prunes expired timestamps for its own key first, the sum stays small (typically ~1 per active IP after the per-call prune). The modulo therefore only hits zero by coincidence; under a single hot IP it never fires. Memory grows unbounded.
Fix: Per-instance call counter (
_PURGE_EVERY_N_CALLS = 1024). Purge runs deterministically every N requests regardless of traffic shape.3. Non-UTF-8 source files surface as HTTP 500 — bug (M / S)
server/src/decision_hub/domain/publish.py:124-128called.decode()(strict UTF-8) on every scannable file. A.pyentry containing arbitrary bytes therefore raisedUnicodeDecodeError, which/v1/publishdoesn't catch — itsexceptclause only handles(ValueError, zipfile.BadZipFile). The user gets a 500.Fix:
errors="replace"so the scanner still sees a string, and append tounscanned_filesso the gauntlet's partial-coverage check downgrades the grade.SKILL.mdand lockfiles: keep strict UTF-8 (they drive identity and dependency resolution); raise a cleanValueError(→ 422) when malformed.4.
seo_routes.sitemap_xmlcached a StarletteResponseobject — bug (M / S)server/src/decision_hub/api/seo_routes.pystashed the constructedResponsein the TTL cache and returned the cached instance directly on subsequent hits. Response objects carry mutable per-request state (header finalisation, charset, etc.) and the Starlette docs are explicit they're not meant to be shared.Fix: Split rendering into
_build_sitemap_xml(conn) -> strand cache only the XML body. The route builds a freshResponseon every hit.Issues considered but deferred
server/src/decision_hub/infra/database.pyis 3,465 lines — splitting by domain table grouping would be valuable but is out of scope (architectural M/L).server/src/decision_hub/api/registry_routes.pyis 1,352 lines and mixes registry, eval runs, visibility, and access endpoints — same reasoning._run_to_response/_tracker_to_responseconverters duplicate ISO-format date logic — low value, skipped.Test plan
uv run --package decision-hub-server --extra dev pytest server/tests -m "not slow"— 945 passed, 34 deselecteduv run --package dhub-core --extra dev pytest shared/tests— 98 passeduvx ruff check .— cleanuvx ruff format --check .— cleanuvx mypy server/src/decision_hub/api/rate_limit.py server/src/decision_hub/api/registry_routes.py server/src/decision_hub/api/seo_routes.py server/src/decision_hub/domain/publish.py— cleantest_rate_limit.py::TestStaleIpPurge::test_idle_ips_eventually_evicted— idle IPs dropped after the windowtest_rate_limit.py::TestStaleIpPurge::test_purge_cadence_does_not_depend_on_per_ip_rate— single hot IP still triggers purgetest_rate_limit.py::TestGetOrCreateLimiter— lazy init / identity / distinctnesstest_publish.py::TestNonUtf8Handling::test_binary_source_file_is_replaced_and_flaggedtest_publish.py::TestNonUtf8Handling::test_non_utf8_skill_md_raises_value_errortest_publish.py::TestNonUtf8Handling::test_non_utf8_lockfile_raises_value_errortest_seo_routes.py::TestSitemapCaching::test_cache_stores_xml_string_not_response_objecttest_seo_routes.py::TestSitemapCaching::test_cache_hit_returns_fresh_response_with_same_bodytest_seo_routes.py::TestSitemapCaching::test_ttl_zero_disables_cachehttps://claude.ai/code/session_016diHyWN9knNJAKe5VwmezW
Generated by Claude Code