Skip to content

Commit a87f70c

Browse files
Merge pull request #1 from going-dev/feat/surface-google-errorresponse
Surface Google's HTTP-200 ErrorResponse envelope as a typed error
2 parents b06e598 + b70f761 commit a87f70c

4 files changed

Lines changed: 155 additions & 3 deletions

File tree

fli/search/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .dates import DatePrice, SearchDates
22
from .exceptions import (
3+
GoogleFlightsUpstreamError,
34
SearchClientError,
45
SearchConnectionError,
56
SearchHTTPError,
@@ -15,4 +16,5 @@
1516
"SearchTimeoutError",
1617
"SearchConnectionError",
1718
"SearchHTTPError",
19+
"GoogleFlightsUpstreamError",
1820
]

fli/search/_wire.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
from collections.abc import Iterator
3030
from typing import Any
3131

32+
from fli.search.exceptions import GoogleFlightsUpstreamError
33+
3234
logger = logging.getLogger(__name__)
3335

3436
_PREFIX = b")]}'"
@@ -94,14 +96,62 @@ def iter_wrb_chunks(body: str | bytes) -> Iterator[Any]:
9496
yield from _chunks_from_outer(outer)
9597

9698

99+
def _grpc_error_in_frame(frame: Any) -> tuple[int, str | None] | None:
100+
"""Detect Google's structured ErrorResponse envelope in a ``wrb.fr`` frame.
101+
102+
A success frame is ``["wrb.fr", null, "<json>"]`` with the payload at index
103+
2. When Google rejects the request it still answers HTTP 200 but replaces
104+
that payload with an error block, e.g.::
105+
106+
["wrb.fr", null, null, null, null,
107+
[13, null, [["type.googleapis.com/travel.frontend.flights.ErrorResponse", ...]]]]
108+
109+
The leading int (``13`` here) is a gRPC status code (13 = INTERNAL). Returns
110+
``(grpc_code, type_url)`` when such a block is present, else ``None``.
111+
``bool`` (an ``int`` subclass) and code ``0`` (OK) are deliberately not
112+
treated as errors so neither trips a false positive.
113+
"""
114+
if not isinstance(frame, list):
115+
return None
116+
for element in frame:
117+
if not (isinstance(element, list) and len(element) >= 3):
118+
continue
119+
code = element[0]
120+
if type(code) is not int or code == 0:
121+
continue
122+
try:
123+
type_url = element[2][0][0]
124+
except (IndexError, TypeError):
125+
type_url = None
126+
if isinstance(type_url, str) and type_url.endswith(".ErrorResponse"):
127+
return code, type_url
128+
return None
129+
130+
97131
def _chunks_from_outer(outer: Any) -> Iterator[Any]:
98-
"""Walk a top-level chunk list and yield decoded inner-JSON payloads."""
132+
"""Walk a top-level chunk list and yield decoded inner-JSON payloads.
133+
134+
Raises :class:`~fli.search.exceptions.GoogleFlightsUpstreamError` when a
135+
frame carries Google's structured ErrorResponse envelope (an HTTP-200
136+
upstream rejection). Without this, the error frame's payload slot is null,
137+
so the reader would skip it and the caller would see a silent empty result
138+
indistinguishable from "no flights".
139+
"""
99140
if not isinstance(outer, list):
100141
return
101142
for row in outer:
102-
if not isinstance(row, list) or len(row) < 3:
143+
if not isinstance(row, list):
103144
continue
104-
if row[0] != "wrb.fr":
145+
error = _grpc_error_in_frame(row)
146+
if error is not None:
147+
grpc_code, type_url = error
148+
logger.warning(
149+
"Google Flights returned an ErrorResponse (gRPC %s, %s)",
150+
grpc_code,
151+
type_url,
152+
)
153+
raise GoogleFlightsUpstreamError(grpc_code, type_url=type_url)
154+
if len(row) < 3 or row[0] != "wrb.fr":
105155
continue
106156
inner = row[2]
107157
if not isinstance(inner, str) or not inner:

fli/search/exceptions.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,60 @@ def __init__(self, message: str, *, status_code: int | None = None):
2828
"""Store the HTTP status alongside the message for richer logging."""
2929
super().__init__(message)
3030
self.status_code = status_code
31+
32+
33+
# Canonical gRPC status codes (0-16) carried by Google's ErrorResponse envelope.
34+
# Used only to label the exception message; the numeric code is authoritative.
35+
_GRPC_CODE_NAMES = {
36+
0: "OK",
37+
1: "CANCELLED",
38+
2: "UNKNOWN",
39+
3: "INVALID_ARGUMENT",
40+
4: "DEADLINE_EXCEEDED",
41+
5: "NOT_FOUND",
42+
6: "ALREADY_EXISTS",
43+
7: "PERMISSION_DENIED",
44+
8: "RESOURCE_EXHAUSTED",
45+
9: "FAILED_PRECONDITION",
46+
10: "ABORTED",
47+
11: "OUT_OF_RANGE",
48+
12: "UNIMPLEMENTED",
49+
13: "INTERNAL",
50+
14: "UNAVAILABLE",
51+
15: "DATA_LOSS",
52+
16: "UNAUTHENTICATED",
53+
}
54+
55+
56+
class GoogleFlightsUpstreamError(SearchClientError):
57+
"""Google answered HTTP 200 with a structured ErrorResponse envelope.
58+
59+
Instead of flight data, the response carries a gRPC status code (e.g.
60+
13 = INTERNAL). This is an upstream rejection, not a parse failure and not
61+
a genuine "no results" (which still decodes to ``None``/empty). The numeric
62+
code is surfaced via ``grpc_code`` so a caller can apply its own retry or
63+
alerting policy rather than fli hard-coding one: a default retry during a
64+
broad outage would turn a fleet of clients into a retry storm and deepen
65+
the upstream's gating of everyone.
66+
67+
Attributes:
68+
grpc_code: The gRPC status code from the envelope (e.g. 13 = INTERNAL),
69+
or ``None`` if it could not be extracted.
70+
type_url: The protobuf type URL Google attached, when present.
71+
72+
"""
73+
74+
def __init__(self, grpc_code: int | None, type_url: str | None = None):
75+
"""Build the error from the gRPC status code and optional type URL."""
76+
self.grpc_code = grpc_code
77+
self.type_url = type_url
78+
name = _GRPC_CODE_NAMES.get(grpc_code) if grpc_code is not None else None
79+
if grpc_code is None:
80+
label = "unknown"
81+
else:
82+
label = f"{grpc_code} {name}" if name else str(grpc_code)
83+
super().__init__(
84+
f"Google Flights rejected the request with an ErrorResponse "
85+
f"envelope (gRPC status {label}) instead of flight data. This is "
86+
f"an upstream error, not an empty result."
87+
)

tests/search/test_wire.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import json
44

5+
import pytest
6+
57
from fli.search._wire import iter_wrb_chunks, parse_first_wrb_payload
8+
from fli.search.exceptions import GoogleFlightsUpstreamError
69

710

811
def _single_chunk(payload):
@@ -141,3 +144,43 @@ def test_skips_invalid_inner_to_find_second_valid_chunk(self):
141144
outer = [["wrb.fr", None, bad_inner], ["wrb.fr", None, good_inner]]
142145
body = ")]}'\n\n" + json.dumps(outer)
143146
assert parse_first_wrb_payload(body) == [42]
147+
148+
149+
_ERROR_TYPE_URL = "type.googleapis.com/travel.frontend.flights.ErrorResponse"
150+
151+
152+
def _error_envelope(grpc_code=13, type_url=_ERROR_TYPE_URL):
153+
"""Build Google's HTTP-200 ErrorResponse envelope.
154+
155+
The success payload slot (index 2) is null; the error block carries the
156+
gRPC status code and the ErrorResponse type URL.
157+
"""
158+
outer = [["wrb.fr", None, None, None, None, [grpc_code, None, [[type_url]]]]]
159+
return ")]}'\n\n" + json.dumps(outer)
160+
161+
162+
class TestErrorEnvelope:
163+
def test_iter_raises_with_grpc_code(self):
164+
with pytest.raises(GoogleFlightsUpstreamError) as exc:
165+
list(iter_wrb_chunks(_error_envelope(13)))
166+
assert exc.value.grpc_code == 13
167+
assert exc.value.type_url.endswith(".ErrorResponse")
168+
assert "13 INTERNAL" in str(exc.value)
169+
170+
def test_parse_first_raises(self):
171+
with pytest.raises(GoogleFlightsUpstreamError):
172+
parse_first_wrb_payload(_error_envelope(8))
173+
174+
def test_success_frame_still_parses(self):
175+
# Detection must not disturb a normal response.
176+
body = _single_chunk([1, "ok", [2, 3]])
177+
assert list(iter_wrb_chunks(body)) == [[1, "ok", [2, 3]]]
178+
179+
def test_non_error_type_url_does_not_false_positive(self):
180+
# An int-leading block whose type URL is not an ErrorResponse is ignored.
181+
body = _error_envelope(13, type_url="type.googleapis.com/foo.Bar")
182+
assert list(iter_wrb_chunks(body)) == []
183+
184+
def test_grpc_code_zero_is_not_an_error(self):
185+
# Code 0 is OK; it must not raise.
186+
assert list(iter_wrb_chunks(_error_envelope(0))) == []

0 commit comments

Comments
 (0)