|
29 | 29 | from collections.abc import Iterator |
30 | 30 | from typing import Any |
31 | 31 |
|
| 32 | +from fli.search.exceptions import GoogleFlightsUpstreamError |
| 33 | + |
32 | 34 | logger = logging.getLogger(__name__) |
33 | 35 |
|
34 | 36 | _PREFIX = b")]}'" |
@@ -94,14 +96,62 @@ def iter_wrb_chunks(body: str | bytes) -> Iterator[Any]: |
94 | 96 | yield from _chunks_from_outer(outer) |
95 | 97 |
|
96 | 98 |
|
| 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 | + |
97 | 131 | 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 | + """ |
99 | 140 | if not isinstance(outer, list): |
100 | 141 | return |
101 | 142 | for row in outer: |
102 | | - if not isinstance(row, list) or len(row) < 3: |
| 143 | + if not isinstance(row, list): |
103 | 144 | 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": |
105 | 155 | continue |
106 | 156 | inner = row[2] |
107 | 157 | if not isinstance(inner, str) or not inner: |
|
0 commit comments