Repository navigation
fix(marketguard): stop the API stalling on every snapshot refresh - #20
Conversation
The public API went unresponsive for seconds at a time whenever a market snapshot expired, which uptime monitors reported as the service being offline. Two independent causes: 1. The Hypixel refresh only ever ran inside a client request. The first caller after the cache TTL expired paid for paginating the whole auction house, and the auction decode (base64 + gzip + NBT for ~100k items, measured at 5-10s) ran directly on the event loop. With WEB_CONCURRENCY=1 that froze every other request, the container healthcheck and the public readiness probe for the full duration. 2. /api/v1/ready reported any snapshot older than the 60s cache TTL as degraded (HTTP 206). Hypixel only regenerates these datasets about once a minute, so readiness flapped continuously for monitors that require HTTP 200. Changes: - Add a background refresher that keeps the Lowest BIN and Bazaar snapshots inside their TTL on a jittered timer, so requests read a warm snapshot from MariaDB instead of triggering an upstream fetch. It backs off to a shorter retry interval on upstream failure and hands refreshing back to the request path on shutdown. - Move the auction decode into a worker thread via asyncio.to_thread. - Make the snapshot read path lock-free with double-checked locking, so a running refresh no longer queues up client requests behind it. - Carry per-snapshot metadata alongside the snapshot it belongs to instead of in mutable service state, so a concurrent refresh cannot pair a price with another snapshot's auctioneer UUID. - Add a readiness grace window so a normally-aged snapshot reports 200. New settings (all with safe defaults): MARKETGUARD_BACKGROUND_REFRESH_ENABLED, _INTERVAL_SECONDS, _RETRY_SECONDS and MARKETGUARD_READINESS_FRESH_GRACE_SECONDS. Measured with a real Uvicorn server and an out-of-process probe over 90k auctions: worst-case probe response drops from 5.00s (probe timeout, i.e. recorded as down) to 0.07s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187ZiUfeTQFKtLB4K6JFGWa
CI:
|
| run | commit | result |
|---|---|---|
#41 (main, push) |
87bb1b2 |
2 failed, 111 passed |
| #42 (this PR) | 605aa9a |
2 failed, 121 passed |
Same two tests in both, and the 10 new tests added here all pass:
test_players_query_returns_teapot_for_invalid_server_hypixel_api_key—MarketGuardStorageError: Failed to initialize MarketGuard MariaDB schema.test_players_query_coalesces_identical_cache_misses_and_tracks_efficiency—assert [419, 419, 419] == [200, 200, 200]
Not a flake and not infrastructure — both are deterministic test bugs:
- The first test passes no MarketGuard services to
create_app, soregister_marketguard_routesconstructs a realMarketGuardStorage, which tries to connect to MariaDB on127.0.0.1:3306. The workflow runspytestwith no database service, so schema init fails. Every other test in the file passes a_MemoryMarketGuardStorageinstead. - The second expects
200but configures no Hypixel API key, andplayers_queryreturns419whenhypixel_api_keyis empty — so it asserts against behaviour the route no longer has.
Proposed patch (verified locally: 123 passed, 0 failed)
Not applied here, to keep this PR scoped to the availability bug. Happy to push it here or open a separate PR — whichever you prefer.
--- a/tests/test_marketguard_api.py
+++ b/tests/test_marketguard_api.py
@@ -1046,9 +1046,12 @@ def test_players_query_returns_teapot_for_invalid_server_hypixel_api_key(tmp_pat
return httpx.Response(403, json={"success": False, "cause": "Invalid API key"})
settings = _marketguard_settings(hypixel_api_key="invalid-hypixel-key")
+ marketguard_service, marketguard_bazaar_service = _noop_marketguard_services(settings)
app = create_app(
training_hub_settings=_training_hub_settings(tmp_path),
marketguard_settings=settings,
+ marketguard_service=marketguard_service,
+ marketguard_bazaar_service=marketguard_bazaar_service,
marketguard_player_service=_marketguard_player_service(
settings,
_hypixel_handler,
@@ -1141,7 +1144,7 @@ def test_players_query_coalesces_identical_cache_misses_and_tracks_efficiency()
async def aclose(self) -> None:
return None
- settings = _marketguard_settings(players_rate_limit_per_minute=3)
+ settings = _marketguard_settings(players_rate_limit_per_minute=3, hypixel_api_key="test-key")
marketguard_service, marketguard_bazaar_service = _noop_marketguard_services(settings)
counting_player_service = _CountingPlayerService()
app = create_marketguard_app(Worth fixing soon either way: while CI is red by default, a real regression in a future PR is easy to miss.
Generated by Claude Code
Both have been failing on main since before this branch (run #41 on 87bb1b2: 2 failed, 111 passed), and they are deterministic test bugs, not infrastructure: - test_players_query_returns_teapot_for_invalid_server_hypixel_api_key passed no MarketGuard services to create_app, so register_marketguard_routes constructed a real MarketGuardStorage and tried to reach MariaDB on 127.0.0.1:3306. The CI workflow runs pytest with no database service. Every other test in the file passes an in-memory storage instead; do the same here. - test_players_query_coalesces_identical_cache_misses_and_tracks_efficiency expected HTTP 200 while configuring no Hypixel API key, but players_query returns 419 when the key is empty. Configure a key so the test exercises the coalescing behaviour it is actually about. Full suite now passes: 123 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187ZiUfeTQFKtLB4K6JFGWa
pip-audit fails the Server Security workflow on cryptography 48.0.1: PYSEC-2026-3554 fixed in 49.0.0 PYSEC-2026-3553 fixed in 49.0.0 PYSEC-2026-3552 fixed in 50.0.0 50.0.0 is the lowest version covering all three advisories. This step had never been reached before: the test step failed first, so the audit finding stayed hidden until the preceding commit made the suite pass. The project uses cryptography only for Fernet in app/training_hub/core/mfa.py, whose API is unchanged across these releases, and webauthn 2.7.1 resolves against 50.0.0 without conflict. Verified locally: full install, 123 passed, and pip-audit no longer reports cryptography. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187ZiUfeTQFKtLB4K6JFGWa
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 15a5aa19cf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # Only now is a request guaranteed to find a snapshot it can serve | ||
| # without going upstream itself. | ||
| self._service.disable_inline_refresh() |
There was a problem hiding this comment.
Disable inline refresh before the first attempt
When the first scheduled refresh after a restart fails while MariaDB already has an expired-but-still-servable snapshot, this line is never reached, so inline refresh remains enabled. LowestBinService._load_snapshot and BazaarService.get_bazaar then skip their nonblocking stale-data branches, and concurrent cache misses perform serialized upstream requests before falling back; during a Hypixel outage this recreates long request stalls until a background attempt succeeds. Disable inline refresh when the refresher starts rather than only after a successful refresh.
Useful? React with 👍 / 👎.
| if settings.readiness_fresh_grace_seconds > settings.stale_if_error_seconds: | ||
| raise ValueError( | ||
| "MARKETGUARD_READINESS_FRESH_GRACE_SECONDS must not exceed MARKETGUARD_STALE_IF_ERROR_SECONDS." |
There was a problem hiding this comment.
Bound the full readiness window by the stale cutoff
For allowed settings such as a 300-second cache TTL, 300-second stale cutoff, and 300-second grace, this validation passes even though readiness_fresh_seconds becomes 600 seconds. At snapshot age 400, /api/v1/ready therefore returns 200 while the data services refuse to serve that snapshot after an upstream failure and return 503. Compare cache_ttl_seconds + readiness_fresh_grace_seconds against the stale cutoff, or cap the readiness threshold, so unusable data is never reported as healthy.
Useful? React with 👍 / 👎.
Problem
The public API went unresponsive for seconds at a time whenever a market snapshot expired — which an uptime monitor records as the service being offline. Two independent causes, both reproduced and measured:
1. The Hypixel refresh only ever ran inside a client request.
LowestBinService.get_lowest_bins()refreshed on demand: the first caller after the 60s cache TTL expired paid for paginating the entire auction house, and the auction decode ran directly on the event loop:Benchmarked on real-shaped payloads:
With
WEB_CONCURRENCY=1(kept deliberately, per DEPLOY.md, for the process-local rate limiter) that froze everything for the duration — including/api/v2/lowestbinand/api/v1/bazaarthemselves, which is what the uptime monitor polls. On top of that, the service'sasyncio.Lockwas held across the whole refresh, so concurrent callers queued behind it even before the decode started. This is the cause of the observed availability drop.2.
/api/v1/readyreported healthy snapshots as degraded.Readiness used
cache_ttl_seconds(60s) as its freshness bound. Hypixel only regenerates these datasets about once per minute, so a perfectly normal snapshot was routinely just over the line and returned HTTP 206. Not what the monitor in this deployment watches, but wrong on its own terms and fixed here too.Changes
app/marketguard_api/refresher.py(new) — background refresher keeping the Lowest BIN and Bazaar snapshots inside their TTL on a jittered timer. Requests read a warm snapshot from MariaDB instead of triggering an upstream fetch. Backs off to a shorter retry interval on upstream failure, and hands refreshing back to the request path on shutdown so nothing is left unrefreshed.service.py— auction decode moved to a worker thread (asyncio.to_thread); read path made lock-free via double-checked locking so a running refresh no longer queues up client requests; per-snapshot metadata now travels with the snapshot it belongs to rather than living in mutable service state, so a concurrent refresh cannot pair a price with another snapshot's auctioneer UUID.routes.py— readiness grace window, so a normally-aged snapshot reports 200.main.py/app/main.py— refreshers started and stopped by the app lifespan, in both the standaloneapimode and the combinedplatformmode.tests/test_marketguard_api.py— also repairs two player-query tests that had been failing onmainsince before this branch (see the second commit); they were deterministic test bugs, not infrastructure.New settings, all with safe defaults and documented in
.env.example,README.mdandDEPLOY.md:MARKETGUARD_BACKGROUND_REFRESH_ENABLEDtrueMARKETGUARD_BACKGROUND_REFRESH_INTERVAL_SECONDS0(= cache TTL)MARKETGUARD_BACKGROUND_REFRESH_RETRY_SECONDS15MARKETGUARD_READINESS_FRESH_GRACE_SECONDS120Verification
End-to-end against a real Uvicorn server with an out-of-process probe (an in-process probe cannot measure an event-loop stall — it gets stalled too), 90,000 synthetic auctions, steady client traffic:
10 new tests. The key regression test asserts on thread identity rather than timing, so it is deterministic — verified to fail when the
to_threadhand-off is reverted:Full suite: 123 passed, 0 failed.
Deployment note
No migration needed. After deploy,
docker compose logs scamscreener-apishould showStarted MarketGuard lowestbin background refresherand the same forbazaar.Monitoring:
/api/v2/lowestbinand/api/v1/bazaarare now served from the stored snapshot and no longer block on an upstream fetch, so they are safe to poll directly./api/v1/readyis also available — treat both200and206as up there, since206means "serving slightly stale data" and only503means no usable snapshot./api/v1/healthis intentionally403from public networks and must not be monitored.🤖 Generated with Claude Code
https://claude.ai/code/session_0187ZiUfeTQFKtLB4K6JFGWa