Conversation
…llback
Some searches exceeded 4 seconds with no way to tell where the time went.
Instrument the full request path first, then fix what the measurements
actually pointed at.
Root cause — Gemini was not the bottleneck, its transport was.
Provider calls went through urllib.request.urlopen on a worker thread, so
every model call opened a fresh DNS + TCP + TLS connection. Over 25
sequential calls the median was 501 ms but two calls took 20.4 s and
23.5 s: ~8% of searches blew past any 4 s budget at random, with no retry
involved. The same 25 calls over a pooled httpx client maxed out at
856 ms.
Two further costs, both previously invisible because totalLatencyMs
started inside the service, after FastAPI had resolved dependencies:
- OpenEntity warm-up takes 2.2-4.0 s (11,269 entities); a request landing
in that window waits for it. A cold request measured 2,835 ms at the
client while the backend reported 1,024 ms.
- NullPool opened a new MySQL connection per request: 178 ms each,
against 30 ms from a pool.
Ruled out by measurement: network (~35 ms warm), serialization (0.2 ms),
entity resolution (p50 27 ms), SearchPlan build (0.0 ms).
Backend
- New app/observability: request-scoped phase timings, contextvar request
identity, JSON log formatter, and middleware that measures the whole
request starting before dependency resolution.
- Pooled process-wide httpx.AsyncClient replaces per-call urllib, with
separate connect and read timeouts.
- Real SQLAlchemy pool with pre-ping and recycle, replacing NullPool.
- Gemini API key moves from the URL query string to the x-goog-api-key
header so URL logging on any hop cannot capture it.
- App loggers lifted above uvicorn's default config, which is why the
warm-up cost never reached production logs.
- Query-understanding service is memoised instead of rebuilt per request.
Flutter
- One authoritative LatencyPolicy; the HTTP client's timeouts derive from
it so client and coordinator cannot disagree.
- SearchLatencyCoordinator implements the progressive fallback as a
cancellation-aware event stream: known offline answers locally at once;
otherwise online starts immediately alongside a safe local answer and
gets a 4,000 ms budget. Past the budget the local result is shown as
offline_fallback while the online request keeps running, and its late
result is offered ("HQ has fresh results"), never applied. A query the
local parser cannot safely answer waits for the authoritative result
instead of fabricating one.
- Deletes OfflineSearchRouter. It had a full 4-second budget and unit
tests but the search screen never called it, so the shipped behaviour
was only "offline -> local" and "backend error -> local". Consolidating
it leaves exactly one 4-second rule.
- Connectivity is probed once per search rather than twice, and the
previous subscription is cancelled without awaiting it, so a new query
no longer queues behind the old one's teardown.
Fixes found while testing: a late response carrying an error was offered
as a fresh result, and cancelling a search left a timer alive past
dispose.
Steady state after the changes: p50 1,106 ms, p95 1,999 ms, max
2,401 ms, 0/25 over 4 s. Cold start remains the one case that breaches
the budget, which is what the fallback exists for.
Tests: 227 backend (11 new for instrumentation), 27 Flutter latency
covering all 15 scenarios, offline benchmark still gating at
wrong_confident = 0 with 16/16 execution parity, flutter analyze clean in
every changed file.
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.
What this does
Instruments the whole search path first, then fixes what the measurements pointed at, then adds the 4-second progressive offline fallback on top.
Full report: https://claude.ai/code/artifact/ebb324eb-f0e1-4b85-af55-17d306c915ea
Root cause — Gemini was not the bottleneck, its transport was
Provider calls went through
urllib.request.urlopenon a worker thread, so every model call opened a fresh DNS + TCP + TLS connection. Over 25 sequential calls the median was 501 ms but two calls took 20.4 s and 23.5 s — roughly 8% of searches blowing past any 4-second budget at random, with no retry involved. The same 25 calls over a pooledhttpxclient maxed out at 856 ms.Two further costs, both previously invisible because
totalLatencyMsstarted inside the service, after FastAPI had already resolved dependencies:NullPoolopened a new MySQL connection per request: 178 ms each, versus 30 ms from a pool.Ruled out by measurement: network (~35 ms warm), serialization (0.2 ms), entity resolution (p50 27 ms), SearchPlan build (0.0 ms).
Backend
app/observability/: request-scoped phase timings, contextvar request identity, JSON log formatter, and middleware that measures the whole request starting before dependency resolution — so DB connect and index warm-up can no longer hide outside the reported total.httpx.AsyncClientreplaces per-callurllib, with separate connect and read timeouts so a stalled connect fails fast.pool_pre_pingandpool_recycle, replacingNullPool.x-goog-api-keyheader, so URL logging on any hop cannot capture it.Timing records carry a request id, durations and small flags only. Query text, transcripts and credentials are stripped by an explicit deny-list.
Flutter
LatencyPolicy. The HTTP client's timeouts derive from it, so client and coordinator cannot disagree.SearchLatencyCoordinatorimplements the progressive fallback as a cancellation-aware event stream.OfflineSearchRouter. It had a full 4-second budget implementation and passing unit tests, but the search screen never called it — the shipped behaviour was only "offline → local" and "backend error → local". Consolidating it leaves exactly one 4-second rule.Runtime policy
offline_fallback; online keeps running, never cancelledTwo bugs found while testing
Both fixed.
Results
Steady state after the changes: p50 1,106 ms, p95 1,999 ms, max 2,401 ms, 0/25 over 4 s. Cold start remains the one case that breaches the budget — which is what the fallback exists for.
tests/unit+tests/paritywrong_confident = 0, coverage 88.9%, execution parity 16/16flutter analyzeThe 4 Flutter failures are pre-existing live-Gemini eval tests in
test/eval/that assert on model output wording. None imports a changed module, and they fail identically onmain.Reviewer notes
SearchQuery, and the latter breaks the rule that identity resolution belongs to the backend. Its confidence is calibrated against a snapshot, not the live index. Worth doing as its own project with its own benchmark gate — the instrumentation already emitsquery_understanding_source, so the skip rate is measurable the day it lands./ready, or persist the built index. The 3.6 s spent fetching 11,269 rows also suggests the warm-up query itself is worth a look.workers × (pool_size + max_overflow) × replicasstays under MySQL'smax_connections.test/latency/progressive_fallback_screen_test.dartexisted but had never compiled green — it completed the parser on raw text while the service normalizes first ("rallies in ireland"→"rallies in Ireland"). Fixed the harness, not the assertions.clockas a direct dependency so latency is measured through a zone-overridable clock — real durations in production, fake-clock durations under widget tests.🤖 Generated with Claude Code