diff --git a/fli/search/__init__.py b/fli/search/__init__.py index 91dc068f..48b60672 100644 --- a/fli/search/__init__.py +++ b/fli/search/__init__.py @@ -1,5 +1,6 @@ from .dates import DatePrice, SearchDates from .exceptions import ( + SearchBackendError, SearchClientError, SearchConnectionError, SearchHTTPError, @@ -15,4 +16,5 @@ "SearchTimeoutError", "SearchConnectionError", "SearchHTTPError", + "SearchBackendError", ] diff --git a/fli/search/_wire.py b/fli/search/_wire.py index ee889b32..9b7997fb 100644 --- a/fli/search/_wire.py +++ b/fli/search/_wire.py @@ -3,9 +3,9 @@ The Service returns JSONP-flavoured responses of the form:: )]}'\n\n - \n + \n [["wrb.fr", null, ""]] - \n + \n [["wrb.fr", null, ""]] ... @@ -13,10 +13,14 @@ the legacy parsers in this package could get away with `lstrip(")]}'")`. `GetBookingResults` emits two chunks, so we need a proper multi-chunk reader. -Important quirk: the length headers count UTF-8 **bytes**, not Python string -characters. When the response contains any non-ASCII characters (which it -sometimes does — airport names, airline names) the offsets diverge, so the -reader must operate over the byte representation of the body. +Important quirk: the length headers are **not** a dependable frame +delimiter. They count the chunk plus its two surrounding newlines, but in +characters rather than UTF-8 bytes, so any response carrying non-ASCII text +(accented airport or airline names) desynchronises a byte-oriented reader — +and an ASCII response hides the difference entirely. Rather than encode a +guess about Google's convention, this reader ignores the announced length +and lets the JSON grammar delimit each chunk, which is correct either way. +The headers are still used to re-synchronise after a malformed chunk. This module centralises that reader and exposes :func:`iter_wrb_chunks` which yields the decoded inner JSON of each ``wrb.fr`` chunk. @@ -26,76 +30,181 @@ import json import logging +import re from collections.abc import Iterator -from typing import Any +from typing import Any, NamedTuple + +from fli.search.exceptions import SearchBackendError logger = logging.getLogger(__name__) -_PREFIX = b")]}'" +_PREFIX = ")]}'" + +# Framing noise between two chunks: the length header and the newlines +# around it. Skipped wholesale — the header's value is never trusted. +_FRAMING_CHARS = "0123456789 \t\r\n" + +# A chunk boundary in the raw stream: newline, decimal length header, +# newline, then the "[" that opens the next chunk. Literal newlines are +# escaped inside JSON strings, so this can never match within a payload. +_CHUNK_BOUNDARY = re.compile(r"\n\d+\n(?=\[)") + +# Google reports rejected requests with gRPC's canonical status codes. +_STATUS_NAMES = { + 1: "CANCELLED", + 2: "UNKNOWN", + 3: "INVALID_ARGUMENT", + 4: "DEADLINE_EXCEEDED", + 5: "NOT_FOUND", + 6: "ALREADY_EXISTS", + 7: "PERMISSION_DENIED", + 8: "RESOURCE_EXHAUSTED", + 9: "FAILED_PRECONDITION", + 10: "ABORTED", + 11: "OUT_OF_RANGE", + 12: "UNIMPLEMENTED", + 13: "INTERNAL", + 14: "UNAVAILABLE", + 15: "DATA_LOSS", + 16: "UNAUTHENTICATED", +} + +# Error details are echoed into the exception message, so cap them. +_MAX_DETAIL_CHARS = 200 + + +class _BackendStatus(NamedTuple): + """A rejection reported by an error-envelope ``wrb.fr`` row.""" + + code: int + detail: str | None def iter_wrb_chunks(body: str | bytes) -> Iterator[Any]: """Yield the inner JSON object of every ``wrb.fr`` chunk in ``body``. Robust to single-chunk responses with no length headers (the older - ``GetShoppingResults`` / ``GetCalendarGraph`` shape) — those are parsed - by falling back to a single JSON load over the trimmed body. - """ - if isinstance(body, str): - raw = body.encode("utf-8") - else: - raw = body + ``GetShoppingResults`` / ``GetCalendarGraph`` shape) — those parse the + same way, since chunk boundaries are derived from the JSON itself. - raw = raw.lstrip() - if raw.startswith(_PREFIX): - raw = raw[len(_PREFIX) :] - raw = raw.lstrip() + A response may mix payload chunks and an error row. Raising the moment + the error row is read would make the outcome depend on how far the + caller drains the generator: a caller taking only the first chunk would + never see an error that trails it, while a caller draining fully would + lose every chunk it had already accumulated to the exception. So an + error is recorded and only raised once the body is exhausted without a + single chunk — whatever Google did send is always delivered. - if not raw: - return - - # Fast path: no length headers (legacy single-chunk responses). - if not (b"0" <= raw[:1] <= b"9"): - try: - outer = json.loads(raw.decode("utf-8")) - except (ValueError, json.JSONDecodeError, UnicodeDecodeError): - logger.warning("Failed to decode single-chunk wrb.fr body as JSON", exc_info=True) - return - yield from _chunks_from_outer(outer) - return + Raises: + SearchBackendError: If Google answered with an error envelope and no + usable chunk at all. See :func:`_error_status`. + """ + # ``errors="replace"`` keeps a corrupted transfer from raising here; + # the resulting chunk simply fails to parse and is reported below. + text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body + + text = text.lstrip() + if text.startswith(_PREFIX): + text = text[len(_PREFIX) :] + text = text.lstrip() + + decoder = json.JSONDecoder() + errors: list[_BackendStatus] = [] + yielded = 0 cursor = 0 - while cursor < len(raw): - # Read the decimal length prefix terminated by \n. - end = raw.find(b"\n", cursor) - if end == -1: + while cursor < len(text): + while cursor < len(text) and text[cursor] in _FRAMING_CHARS: + cursor += 1 + if cursor >= len(text): break try: - length = int(raw[cursor:end]) + outer, cursor = decoder.raw_decode(text, cursor) except ValueError: - logger.warning( - "Malformed length header at offset %d; truncating chunk stream", - cursor, - ) - break - # Google's length header counts the leading newline after the header - # AND the trailing newline that separates this chunk from the next. - # We've already consumed the leading newline (it terminated the header), - # so we read `length - 1` bytes which gives JSON + trailing \n. - cursor = end + 1 - chunk_bytes = max(length - 1, 0) - payload = raw[cursor : cursor + chunk_bytes] - cursor += chunk_bytes - try: - outer = json.loads(payload.strip().decode("utf-8")) - except (ValueError, json.JSONDecodeError, UnicodeDecodeError): logger.warning("Discarding malformed wrb.fr chunk", exc_info=True) + boundary = _CHUNK_BOUNDARY.search(text, cursor) + if boundary is None: + break + cursor = boundary.end() continue - yield from _chunks_from_outer(outer) - + for chunk in _chunks_from_outer(outer, errors): + yielded += 1 + yield chunk -def _chunks_from_outer(outer: Any) -> Iterator[Any]: - """Walk a top-level chunk list and yield decoded inner-JSON payloads.""" + if not errors: + return + if not yielded: + raise _backend_error(errors[0]) + logger.warning( + "Google Flights reported error %d (%s) alongside %d usable chunk(s); " + "keeping the partial payload", + errors[0].code, + errors[0].detail or _STATUS_NAMES.get(errors[0].code, "unknown"), + yielded, + ) + + +def _error_status(row: list[Any]) -> _BackendStatus | None: + """Return the status of an error-envelope ``wrb.fr`` row, if any. + + Google reports a rejected request with ``HTTP 200`` and a payload-less + row of the shape ``["wrb.fr", null, null, null, null, [code]]``, e.g. + ``3`` (``INVALID_ARGUMENT``) for a payload it cannot decode or ``13`` + (``INTERNAL``) for a request it declines to serve. The status may carry + a message and a ``google.rpc``-style detail block after the code:: + + [13, null, [["type.googleapis.com/…ErrorResponse", [[…, ""], 0]]]] + + Only a strictly positive integer code counts as a rejection: ``0`` is + gRPC's ``OK`` and would otherwise raise a spurious "error 0", and + ``bool`` is excluded because it is a subclass of ``int``. + """ + if len(row) < 6: + return None + status = row[5] + if not isinstance(status, list) or not status: + return None + code = status[0] + if isinstance(code, bool) or not isinstance(code, int) or code <= 0: + return None + return _BackendStatus(code, _error_detail(status)) + + +def _error_detail(status: list[Any]) -> str | None: + """Summarise the message and detail block trailing a status code.""" + parts: list[str] = [] + message = status[1] if len(status) > 1 else None + if isinstance(message, str) and message: + parts.append(message) + details = status[2] if len(status) > 2 else None + if details: + parts.append(_compact(details)) + return "; ".join(parts) or None + + +def _compact(value: Any) -> str: + """Render a decoded JSON value as a compact, length-capped string.""" + try: + text = json.dumps(value, separators=(",", ":"), ensure_ascii=False) + except (TypeError, ValueError): # pragma: no cover - values come from json.loads + text = repr(value) + if len(text) > _MAX_DETAIL_CHARS: + text = text[: _MAX_DETAIL_CHARS - 1] + "…" + return text + + +def _backend_error(status: _BackendStatus) -> SearchBackendError: + """Build the exception describing a backend rejection.""" + name = _STATUS_NAMES.get(status.code) + code_text = f"{status.code} ({name})" if name else str(status.code) + message = f"Google Flights returned error {code_text} instead of results" + if status.detail: + message = f"{message}: {status.detail}" + return SearchBackendError(message, error_code=status.code, error_detail=status.detail) + + +def _chunks_from_outer(outer: Any, errors: list[_BackendStatus]) -> Iterator[Any]: + """Yield a top-level chunk list's payloads, recording rejections into ``errors``.""" if not isinstance(outer, list): return for row in outer: @@ -105,6 +214,9 @@ def _chunks_from_outer(outer: Any) -> Iterator[Any]: continue inner = row[2] if not isinstance(inner, str) or not inner: + status = _error_status(row) + if status is not None: + errors.append(status) continue try: yield json.loads(inner) @@ -114,7 +226,16 @@ def _chunks_from_outer(outer: Any) -> Iterator[Any]: def parse_first_wrb_payload(body: str | bytes) -> Any: - """Return the inner JSON of the first ``wrb.fr`` chunk, or None.""" + """Return the inner JSON of the first ``wrb.fr`` chunk, or None. + + An error row trailing a usable chunk never raises: the first chunk is + returned and the generator is abandoned. See :func:`iter_wrb_chunks`. + + Raises: + SearchBackendError: If Google answered with an error envelope and no + usable chunk at all. + + """ for chunk in iter_wrb_chunks(body): return chunk return None diff --git a/fli/search/exceptions.py b/fli/search/exceptions.py index ced740a8..0972f726 100644 --- a/fli/search/exceptions.py +++ b/fli/search/exceptions.py @@ -28,3 +28,32 @@ def __init__(self, message: str, *, status_code: int | None = None): """Store the HTTP status alongside the message for richer logging.""" super().__init__(message) self.status_code = status_code + + +class SearchBackendError(SearchClientError): + """Google Flights answered HTTP 200 with an error envelope, not results. + + A rejected request comes back as ``HTTP 200`` carrying a payload-less + ``wrb.fr`` row of the shape ``["wrb.fr", null, null, null, null, + [code]]``. Without this error the response is indistinguishable from + "this route genuinely has no flights". + + Richer rejections carry a status message and/or a ``google.rpc``-style + detail block after the code, which is preserved verbatim (truncated) in + ``error_detail`` — the code alone is often too coarse to debug with. An + ``INTERNAL`` (13), for instance, can mean either "Google declined to + serve this" or "a required request header was missing", and only the + detail block tells the two apart. + """ + + def __init__( + self, + message: str, + *, + error_code: int | None = None, + error_detail: str | None = None, + ): + """Store the backend status code and detail alongside the message.""" + super().__init__(message) + self.error_code = error_code + self.error_detail = error_detail diff --git a/tests/search/test_search_flights.py b/tests/search/test_search_flights.py index d12852c9..58fb0d6f 100644 --- a/tests/search/test_search_flights.py +++ b/tests/search/test_search_flights.py @@ -351,3 +351,66 @@ def test_error_dedups_repeated_reasons(self): msg = str(excinfo.value) assert msg.count("not numeric") == 1 assert "0/10" in msg + + +class TestBackendErrorEnvelope: + """A rejected request must not read as an empty result set.""" + + def _client_with_canned_response(self, body: str) -> SearchFlights: + from unittest.mock import patch + + sf = SearchFlights() + + def _fake_post(url: str, data: object, **kwargs: object) -> object: + return type( + "R", + (), + { + "content": body.encode("utf-8"), + "text": body, + "raise_for_status": lambda self: None, + }, + )() + + patcher = patch.object(sf.client, "post", side_effect=_fake_post) + patcher.start() + return sf + + def _filters(self) -> FlightSearchFilters: + return FlightSearchFilters( + passenger_info=PassengerInfo(adults=1), + flight_segments=[ + FlightSegment( + departure_airport=[[Airport.JFK, 0]], + arrival_airport=[[Airport.LAX, 0]], + travel_date=(datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d"), + ) + ], + ) + + def test_search_raises_instead_of_returning_none(self): + """HTTP 200 + error envelope surfaces the status code, not 'no flights'.""" + import json + + from fli.search import SearchBackendError + + body = ")]}'\n\n" + json.dumps( + [ + ["wrb.fr", None, None, None, None, [13]], + ["di", 39], + ["af.httprm", 38, "-1963517503", 5], + ] + ) + sf = self._client_with_canned_response(body) + with pytest.raises(SearchBackendError, match="13 \\(INTERNAL\\)"): + sf.search(self._filters()) + + def test_genuinely_empty_response_still_returns_none(self): + """A well-formed response with no flight rows keeps returning None.""" + import json + + inner = [[None, None, None, None, "FAKE_SESSION"], None, [[]], None] + outer = [["wrb.fr", None, json.dumps(inner, separators=(",", ":"))]] + body = ")]}'\n\n" + json.dumps(outer) + sf = self._client_with_canned_response(body) + assert sf.search(self._filters()) is None diff --git a/tests/search/test_wire.py b/tests/search/test_wire.py index 05fa22e9..549e4495 100644 --- a/tests/search/test_wire.py +++ b/tests/search/test_wire.py @@ -2,7 +2,10 @@ import json +import pytest + from fli.search._wire import iter_wrb_chunks, parse_first_wrb_payload +from fli.search.exceptions import SearchBackendError def _single_chunk(payload): @@ -13,20 +16,17 @@ def _single_chunk(payload): def _multi_chunk(*payloads): - """Build a multi-chunk response with explicit length prefixes. + """Build a multi-chunk response with byte-counted length prefixes. - Mirrors Google's actual format: each length header counts both the - leading newline that follows the header AND the trailing newline that - separates this chunk from the next (i.e. ``len(outer_json) + 1``). + Each length header counts the chunk plus its two surrounding newlines, + measured in UTF-8 bytes. Google measures in characters instead (see + :func:`_google_framed`); both helpers exist so the reader is pinned as + working under either convention. """ parts = [")]}'\n\n"] for p in payloads: inner_json = json.dumps(p, separators=(",", ":")) outer_json = json.dumps([["wrb.fr", None, inner_json]], separators=(",", ":")) - # The length header counts UTF-8 BYTES (not Python str chars) plus - # the two surrounding newlines. Encoding the JSON before measuring - # keeps the test correct when payloads contain non-ASCII characters - # like accented airport names or Japanese carrier strings. byte_len = len(outer_json.encode("utf-8")) + 2 parts.append(f"{byte_len}\n{outer_json}\n") return "".join(parts) @@ -57,9 +57,7 @@ def test_handles_malformed_inner_json_gracefully(self): assert list(iter_wrb_chunks(body)) == [] def test_non_ascii_chunk_payload(self): - # The length header counts UTF-8 bytes, not characters — confirm a - # payload with multi-byte chars round-trips correctly (regression - # guard for the byte-vs-char-length bug in the test helper). + # Multi-byte payloads round-trip under the byte-counted framing. body = _multi_chunk([1, "東京", "café", "résumé"]) chunks = list(iter_wrb_chunks(body)) assert chunks == [[1, "東京", "café", "résumé"]] @@ -141,3 +139,199 @@ def test_skips_invalid_inner_to_find_second_valid_chunk(self): outer = [["wrb.fr", None, bad_inner], ["wrb.fr", None, good_inner]] body = ")]}'\n\n" + json.dumps(outer) assert parse_first_wrb_payload(body) == [42] + + +def _google_framed(*payloads: object) -> str: + """Build a multi-chunk response framed the way Google actually frames it. + + Measured on a live August 2026 ``GetShoppingResults`` response whose + airport names carry accents: the length header counts the chunk *plus + its two surrounding newlines*, in **characters**. On an ASCII-only + response that is indistinguishable from a byte count, which is why the + checked-in fixtures never exercised the difference. + """ + parts = [")]}'\n\n"] + for p in payloads: + inner_json = json.dumps(p, separators=(",", ":"), ensure_ascii=False) + outer_json = json.dumps( + [["wrb.fr", None, inner_json]], separators=(",", ":"), ensure_ascii=False + ) + parts.append(f"{len(outer_json) + 2}\n{outer_json}\n") + return "".join(parts) + + +def _error_envelope(code: int) -> str: + """Build the HTTP 200 error envelope Google returns for a rejected request.""" + outer = [ + ["wrb.fr", None, None, None, None, [code]], + ["di", 39], + ["af.httprm", 38, "-1963517503", 5], + ] + return ")]}'\n\n" + json.dumps(outer, separators=(",", ":")) + + +def _error_status_row(status: object) -> list[object]: + """Build a payload-less ``wrb.fr`` row carrying an arbitrary status field.""" + return ["wrb.fr", None, None, None, None, status] + + +def _rows_body(*rows: object) -> str: + """Wrap already-built outer rows into a single-chunk response body.""" + return ")]}'\n\n" + json.dumps(list(rows), separators=(",", ":")) + + +def _payload_row(payload: object) -> list[object]: + """Build a normal ``wrb.fr`` row carrying an inner JSON payload.""" + return ["wrb.fr", None, json.dumps(payload, separators=(",", ":"))] + + +class TestNonAsciiFraming: + """Chunks must survive non-ASCII payloads (issue: 'No flights found').""" + + def test_accented_single_chunk_is_not_dropped(self): + payload = [None, None, [[["Aéroport de Paris-Charles de Gaulle", "Düsseldorf"]]]] + assert parse_first_wrb_payload(_google_framed(payload)) == payload + + def test_accented_chunk_does_not_desync_the_stream(self): + body = _google_framed([1, "Aéroport de Paris-Charles de Gaulle"], [2, "beta"]) + assert list(iter_wrb_chunks(body)) == [ + [1, "Aéroport de Paris-Charles de Gaulle"], + [2, "beta"], + ] + + def test_ascii_chunks_still_parse(self): + body = _google_framed([1, "Paris Charles de Gaulle Airport"], [2, "beta"]) + assert list(iter_wrb_chunks(body)) == [ + [1, "Paris Charles de Gaulle Airport"], + [2, "beta"], + ] + + def test_length_header_is_not_trusted(self): + # A header that matches neither the byte nor the character length + # must not affect decoding: chunk boundaries come from the JSON + # grammar, not from the announced length. + inner_json = json.dumps([1, "café"], separators=(",", ":"), ensure_ascii=False) + outer_json = json.dumps( + [["wrb.fr", None, inner_json]], separators=(",", ":"), ensure_ascii=False + ) + body = f")]}}'\n\n999999\n{outer_json}\n1\n{outer_json}\n" + assert list(iter_wrb_chunks(body)) == [[1, "café"], [1, "café"]] + + +class TestErrorEnvelope: + """Payload-less wrb.fr rows carry a status code and must not read as 'no results'.""" + + def test_internal_error_raises(self): + with pytest.raises(SearchBackendError) as excinfo: + parse_first_wrb_payload(_error_envelope(13)) + assert excinfo.value.error_code == 13 + assert "13" in str(excinfo.value) + assert "INTERNAL" in str(excinfo.value) + + def test_invalid_argument_raises(self): + with pytest.raises(SearchBackendError) as excinfo: + list(iter_wrb_chunks(_error_envelope(3))) + assert excinfo.value.error_code == 3 + assert "INVALID_ARGUMENT" in str(excinfo.value) + + def test_unknown_code_still_raises_with_the_number(self): + with pytest.raises(SearchBackendError) as excinfo: + parse_first_wrb_payload(_error_envelope(9999)) + assert excinfo.value.error_code == 9999 + assert "9999" in str(excinfo.value) + + def test_payload_less_row_without_status_is_still_skipped(self): + # Short rows carry no status code — they stay a silent skip. + body = ")]}'\n\n" + json.dumps([["wrb.fr", None, None]]) + assert list(iter_wrb_chunks(body)) == [] + + def test_ok_status_zero_is_not_an_error(self): + # 0 is gRPC's OK; it must not surface as "error 0". + assert list(iter_wrb_chunks(_rows_body(_error_status_row([0])))) == [] + + def test_boolean_status_is_not_an_error(self): + # bool subclasses int — True must not be read as error 1 (CANCELLED). + assert list(iter_wrb_chunks(_rows_body(_error_status_row([True])))) == [] + + def test_negative_status_is_not_an_error(self): + assert list(iter_wrb_chunks(_rows_body(_error_status_row([-1])))) == [] + + +class TestErrorEnvelopeDetail: + """The status may carry a message and detail block worth surfacing.""" + + def test_detail_block_reaches_the_exception(self): + # Shape captured live from GetExploreDestinations: the request id and + # type URL are the debugging hint, not the bare code. + status = [ + 13, + None, + [ + [ + "type.googleapis.com/travel.frontend.flights.ErrorResponse", + [[None, None, 0, "req-abc123"], 0], + ] + ], + ] + with pytest.raises(SearchBackendError) as excinfo: + list(iter_wrb_chunks(_rows_body(_error_status_row(status)))) + assert excinfo.value.error_code == 13 + assert "req-abc123" in excinfo.value.error_detail + assert "ErrorResponse" in excinfo.value.error_detail + assert "req-abc123" in str(excinfo.value) + + def test_status_message_is_surfaced(self): + status = [7, "missing x-same-domain header"] + with pytest.raises(SearchBackendError) as excinfo: + list(iter_wrb_chunks(_rows_body(_error_status_row(status)))) + assert excinfo.value.error_detail == "missing x-same-domain header" + assert "missing x-same-domain header" in str(excinfo.value) + + def test_bare_code_has_no_detail(self): + with pytest.raises(SearchBackendError) as excinfo: + list(iter_wrb_chunks(_error_envelope(13))) + assert excinfo.value.error_detail is None + + def test_oversized_detail_is_truncated(self): + status = [13, None, [["type.googleapis.com/x", ["y" * 5000]]]] + with pytest.raises(SearchBackendError) as excinfo: + list(iter_wrb_chunks(_rows_body(_error_status_row(status)))) + assert len(excinfo.value.error_detail) == 200 + assert excinfo.value.error_detail.endswith("…") + + +class TestErrorEnvelopeWithPartialResults: + """An error row must not destroy chunks Google already sent.""" + + def test_error_after_a_valid_chunk_keeps_the_chunk(self): + body = _rows_body(_payload_row([1, "alpha"]), _error_status_row([13])) + assert list(iter_wrb_chunks(body)) == [[1, "alpha"]] + + def test_error_before_a_valid_chunk_keeps_the_chunk(self): + # Position must not decide the outcome: same body, rows swapped. + body = _rows_body(_error_status_row([13]), _payload_row([1, "alpha"])) + assert list(iter_wrb_chunks(body)) == [[1, "alpha"]] + + def test_error_in_a_later_chunk_of_a_multi_chunk_body(self): + good = json.dumps([_payload_row([1, "alpha"])], separators=(",", ":")) + bad = json.dumps([_error_status_row([13])], separators=(",", ":")) + body = f")]}}'\n\n{len(good) + 2}\n{good}\n{len(bad) + 2}\n{bad}\n" + assert list(iter_wrb_chunks(body)) == [[1, "alpha"]] + + def test_first_payload_is_returned_despite_a_trailing_error(self): + body = _rows_body(_payload_row([1, "alpha"]), _error_status_row([13])) + assert parse_first_wrb_payload(body) == [1, "alpha"] + + def test_error_only_body_still_raises_for_both_consumers(self): + body = _rows_body(_error_status_row([13])) + with pytest.raises(SearchBackendError): + list(iter_wrb_chunks(body)) + with pytest.raises(SearchBackendError): + parse_first_wrb_payload(body) + + def test_undecodable_inner_json_does_not_count_as_a_chunk(self): + # The only payload row is unusable, so the error must still surface. + body = _rows_body(["wrb.fr", None, "{not valid"], _error_status_row([13])) + with pytest.raises(SearchBackendError) as excinfo: + list(iter_wrb_chunks(body)) + assert excinfo.value.error_code == 13