fix(search): decode wrb.fr chunks by JSON grammar, and surface Google's error envelopes - #224
Conversation
…nvelopes Two independent defects in the batchexecute reader, both of which end as "No flights found" for the user. 1. Chunk framing broke on non-ASCII responses. iter_wrb_chunks trusted the decimal length header preceding each chunk and sliced the body by that many UTF-8 bytes. Google counts the chunk plus its two surrounding newlines in *characters*, so the two agree only while the response is pure ASCII — which every checked-in fixture happens to be. Add one accented airport name and the reader slices short: the chunk fails to parse and is discarded, the cursor lands mid-JSON, and the stream is truncated with "Malformed length header at offset N". A Paris-New York search returns nothing at all. Rather than swap one guess about Google's convention for another, stop using the header as a frame delimiter: json.JSONDecoder.raw_decode locates the end of each chunk exactly, under either convention. The headers are still used to re-synchronise after an unparseable chunk, so the previous "skip the bad chunk, keep the rest" behaviour is retained. 2. A rejected request read as an empty result set. When Google declines a request it answers HTTP 200 with a payload-less row -- ["wrb.fr",null,null,null,null,[13]] -- carrying a gRPC status code (3 INVALID_ARGUMENT, 13 INTERNAL). The reader skipped that row as "no inner payload", parse_first_wrb_payload returned None, and the CLI printed "No flights found", indistinguishable from a route with no service. Detect the envelope and raise the new SearchBackendError, which carries the status code and subclasses SearchClientError, so the CLI's existing error path reports "Google Flights returned error 13 (INTERNAL)". Both paths are covered by offline tests over synthetic responses; no network access is involved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Btxbg4GF1AdfozL1amqzbJ
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
| sf = SearchFlights() | ||
|
|
||
| def _fake_post(url, data, **kwargs): # noqa: ANN001 | ||
| return type( |
There was a problem hiding this comment.
Test helpers lack type annotations
The new _fake_post helper omits parameter and return annotations, as do _google_framed and _error_envelope in test_wire.py, weakening static type checking and violating the repository's full-annotation standard.
Context Used: CLAUDE.md (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/search/test_search_flights.py
Line: 365
Comment:
**Test helpers lack type annotations**
The new `_fake_post` helper omits parameter and return annotations, as do `_google_framed` and `_error_envelope` in `test_wire.py`, weakening static type checking and violating the repository's full-annotation standard.
**Context Used:** CLAUDE.md ([source](https://github.com/punitarani/fli/blob/main/CLAUDE.md))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
CLAUDE.md requires full type annotations. The three helpers added by this PR omitted them; the neighbouring pre-existing helpers are left untouched to keep the diff focused.
|
Fixed in ee01284 — thanks. Annotated the three helpers this PR adds: I left the neighbouring pre-existing helpers ( Worth noting for anyone reading the rule:
|
|
Really nice fix — grammar-based decoding is the right call here. While reverse-engineering 1. Raising from inside the generator is position-dependent, and full drains lose partial results. 2. Worth carrying the error detail, not just the code.
3. Fixture evidence for the framing claim. Tiny nit: FYI there's a one-assertion interaction with #226 ( |
Review feedback from @alexechoi on punitarani#224. Raising from inside the generator made the outcome depend on how far the caller drained it: `parse_first_wrb_payload` returns at chunk 0 and never saw a trailing error row, while the full drains (`get_booking_options`, and the explore loop in punitarani#226) lost every chunk already accumulated to the exception. The status is now recorded and raised only when the body is exhausted without a single usable chunk, so both consumption modes agree and whatever Google did send is always delivered. A swallowed error is logged with the chunk count. Also carry the status detail. A live GetExploreDestinations rejection has the shape `[13, null, [["type.googleapis.com/….ErrorResponse", [[…, "<request-id>"], 0]]]]`, where error 13 meant "you omitted the x-same-domain/origin header" rather than "Google declined to serve" — the type URL and request id were the debugging hint, and both were discarded. They are now surfaced in the message and on a new `error_detail` attribute, capped at 200 characters. Finally, only a strictly positive int counts as a rejection: `0` is gRPC's OK and raised a spurious "error 0", and `bool` slipped through as a subclass of `int`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two independent defects in
fli/search/_wire.py. Both surface to the user asNo flights found, which is why they are easy to misread as "this route has no service". The second one is issue #223.1. Chunk framing desynchronises on non-ASCII responses
iter_wrb_chunkstreats the decimal header preceding each chunk as a UTF-8 byte count and slices the body bylength - 1bytes.Measured on a
GetShoppingResultsresponse captured 2026-08-07 (CDG→JFK; 91,043 characters for 91,120 bytes, i.e. 77 multi-byte characters in accented airport and carrier names), the announced length matches neither reading: it counts the chunk plus both surrounding newlines, in characters.On an ASCII-only body the two conventions coincide exactly — which is why the checked-in fixture never exercised the difference.
tests/search/fixtures/booking_results_aa_jfk_lax.bincontains 0 multi-byte characters across 48,444, andheader − chunk == 2for all four of its chunks. A single accent is enough to desynchronise the stream.With any non-ASCII content the slice comes up short. In order:
Unterminated string starting at: line 1 column 17Malformed length header at offset N; truncating chunk streamparse_first_wrb_payloadreturnsNoneand the search reports no flightsFix: stop using the header as a frame delimiter.
json.JSONDecoder().raw_decode()derives the end of each chunk from the JSON grammar, which is correct under either convention and needs no assumption about Google's framing. The headers are still used to resynchronise after an unparseable chunk, so the previous "skip the bad chunk, keep the rest" behaviour is preserved.2. A rejected request is indistinguishable from an empty result set
When Google declines a request it answers HTTP 200 with a payload-less row:
_chunks_from_outerskips that row (no inner payload string),parse_first_wrb_payloadreturnsNone,_fetch_flightsreturnsNone, and the CLI printsNo flights found.Observed codes line up with gRPC canonical statuses:
3INVALID_ARGUMENTfor a payload Google cannot decode,13INTERNALfor a request it declines to serve. A genuinely empty payload gets a plain HTTP 400 instead, so the three cases are distinguishable.Fix: detect the envelope and raise the new
SearchBackendError, carryingerror_code. It subclassesSearchClientError, whichfli/cli/errors.pyalready routes — the user now seesSearch failed. Google Flights returned error 13 (INTERNAL) instead of results. No change to the CLI, and no change to the JSON error contract.SearchParseErrorlives inflights.pyrather thanexceptions.py, so importing it from_wire.pywould introduce a cycle; hence the new exception inexceptions.py.Tests
All offline, over synthetic responses — no requests to Google.
TestNonAsciiFraming(4): an accented single chunk is not dropped; an accented chunk does not desynchronise a multi-chunk stream; ASCII still parses; a header matching neither convention is ignored.TestErrorEnvelope(4): codes 13 and 3 raise with the status name; an unmapped code still raises with the number; a short payload-less row remains a silent skip.TestBackendErrorEnvelopeintests/search/test_search_flights.py(2):search()raises through the full stack; a genuinely empty response still returnsNone.The existing
_multi_chunkhelper (byte-counted headers) is kept beside the new_google_framedhelper (character-counted), pinning the reader as correct under both conventions. 7 of the new tests fail before the fix. The captured multi-chunk fixture test passes before and after.Full suite: no regression against baseline. The remaining failures are the
livetests that require a working Google endpoint, identical before and after this change.The two fixes are independent — happy to split them into separate PRs if you prefer.
Note on the underlying outage
Separately from these two defects,
GetShoppingResultscurrently rejects requests from any plain HTTP client. The endpoint now requires anX-Goog-BatchExecute-Bgrheader that the page's JavaScript produces; without it the response is the error envelope above, which is how we ran into defect 2. That part is outside the scope of this PR — the point here is only that the library should say so rather than report an empty result.Note on the TypeScript port
fli-js/src/search/wire.tsis a direct port and carries both defects (chunkBytes = Math.max(length - 1, 0), androw.length < 3skipping the error envelope). Not touched here — happy to follow up if useful.Investigated, developed and debugged with Claude Opus 5, in collaboration.
Greptile Summary
The PR replaces byte-count-based
wrb.frslicing with JSON-grammar decoding and introduces a typed error for HTTP 200 backend rejection envelopes.SearchBackendErrorand preserves backend status codes and canonical names.Confidence Score: 4/5
The PR appears safe to merge after the non-blocking test-helper typing violations are addressed.
The parser and backend-error changes preserve the established search paths with focused regression coverage; the only accepted issue is incomplete typing in newly added test helpers.
Files Needing Attention: tests/search/test_search_flights.py, tests/search/test_wire.py
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Google Flights HTTP response] --> B[Strip XSSI prefix and framing noise] B --> C[JSONDecoder.raw_decode outer chunk] C -->|Malformed| D[Search for next chunk boundary] D --> C C -->|Decoded| E{wrb.fr row has inner payload?} E -->|Yes| F[Decode and yield inner JSON] E -->|No, status envelope| G[Raise SearchBackendError] E -->|No status| H[Skip row]Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "fix(search): decode wrb.fr chunks by JSO..." | Re-trigger Greptile
Context used:
fli/search/)