From ca1774ac1a40450653fbb6dfa3cf1d6794aa3423 Mon Sep 17 00:00:00 2001 From: Mouhand-Kaddo Date: Tue, 29 Sep 2026 10:31:42 +0400 Subject: [PATCH 1/3] feat: distinguish provider failures in scripted runs --- README.md | 32 +++- src/lecode/cli.py | 26 ++- src/lecode/providers/openai_compat.py | 217 +++++++++++++++++++------- tests/test_headless.py | 86 +++++++++- tests/test_provider_client.py | 132 +++++++++++++++- 5 files changed, 423 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 09d251a..c01c8d5 100644 --- a/README.md +++ b/README.md @@ -127,8 +127,36 @@ lecode --chain "redesign the parser" # brainstorm→plan→code→revie The final answer goes to stdout; a `tokens: in / out · cost: $X.XXXX` summary goes to stderr, so scripts can pipe the answer cleanly. -Exit codes: `0` done · `1` error · `2` startup (missing deps, bad flags, -non-tty `--setup`) · `3` max turns / max loop iterations / context overflow. +Headless, loop, and chain modes use these exit codes: + +| Code | Meaning | +|------|---------| +| `0` | Done | +| `1` | Generic error, including other HTTP failures | +| `2` | Startup error: missing dependencies or required credentials, bad flags, non-tty `--setup` | +| `3` | Max turns, max loop iterations, or context overflow | +| `4` | Provider authentication failure | +| `5` | Provider budget or credit exhaustion | +| `6` | Model/resource not found, including HTTP 404 (which can also mean an incorrect endpoint) | +| `7` | Provider rate limit, after retries are exhausted | +| `8` | Upstream, timeout, conflict, or transport failure | +| `9` | Unknown or malformed in-stream error | + +HTTP and in-stream errors share classification. Recognized +`error.metadata.error_type` values take precedence over symbolic `error.code` +and `error.type`, followed by HTTP status (or a numeric in-stream code). +In-stream codes can be integers or three-digit ASCII strings. Recognized +symbolic codes are `authentication`, `invalid_api_key`, `payment_required`, +`insufficient_quota`, `credit_balance_exhausted`, `model_not_found`, `not_found`, +`rate_limit_exceeded`, `provider_overloaded`, `provider_unavailable`, `server`, +`server_error`, and `timeout`. Classification never guesses from message text. + +Budget failures are not retried, including quota errors reported as HTTP 429. +Other retries use the numeric HTTP/in-stream status when supplied, retaining +the existing set: 408, 409, 429, 500, 502, 503, 504 and transport failures. +Symbolic-only in-stream errors use their corresponding status. +Unknown or malformed in-stream errors are not +retried. Interactive mode continues to display provider failures and stay open. ## A tour of the power features diff --git a/src/lecode/cli.py b/src/lecode/cli.py index e223616..db97ed2 100644 --- a/src/lecode/cli.py +++ b/src/lecode/cli.py @@ -3,7 +3,7 @@ Scope: ``--version``, the startup dependency check (fd / rg / rtk), headless mode (``-p/--prompt``: auto-approved tools, auto-named session, final response on stdout, token/cost summary on stderr, exit codes 0 done / -1 error / 2 startup / 3 max turns), and the interactive TUI (default when +1 error / 2 startup / 3 max turns / 4-9 provider failures), and the interactive TUI (default when no ``-p`` is given): session-name prompt → session on disk → chat, with ``-r/--resume`` and ``-c/--continue`` reopening existing sessions. """ @@ -74,6 +74,24 @@ EXIT_ERROR = 1 EXIT_STARTUP = 2 EXIT_MAX_TURNS = 3 +EXIT_AUTHENTICATION = 4 +EXIT_BUDGET = 5 +EXIT_MODEL_NOT_FOUND = 6 +EXIT_RATE_LIMIT = 7 +EXIT_UPSTREAM = 8 +EXIT_STREAM = 9 + + +def _provider_exit_code(error: ProviderError) -> int: + return { + "authentication": EXIT_AUTHENTICATION, + "budget": EXIT_BUDGET, + "model_not_found": EXIT_MODEL_NOT_FOUND, + "rate_limit": EXIT_RATE_LIMIT, + "upstream": EXIT_UPSTREAM, + "stream": EXIT_STREAM, + }.get(error.category or "", EXIT_ERROR) + #: Value produced when ``-p/--prompt`` is given without an argument: read stdin. _STDIN_MARKER = "" @@ -408,7 +426,7 @@ def run_headless( result = asyncio.run(_run_with_mcp(runtime, client, runner, messages)) except ProviderError as e: typer.echo(f"error: {e}", err=True) - return EXIT_ERROR + return _provider_exit_code(e) except KeyboardInterrupt: _fire_cli_hook(runtime.hooks, INTERRUPT) typer.echo("error: interrupted", err=True) @@ -547,7 +565,7 @@ async def _loop() -> LoopResult: result = asyncio.run(_loop()) except ProviderError as e: typer.echo(f"error: {e}", err=True) - return EXIT_ERROR + return _provider_exit_code(e) except KeyboardInterrupt: _fire_cli_hook(runtime.hooks, INTERRUPT) typer.echo("error: interrupted", err=True) @@ -664,7 +682,7 @@ async def _chain() -> ChainResult: asyncio.run(_chain()) except ProviderError as e: typer.echo(f"error: {e}", err=True) - return EXIT_ERROR + return _provider_exit_code(e) except KeyboardInterrupt: _fire_cli_hook(runtime.hooks, INTERRUPT) typer.echo("error: interrupted", err=True) diff --git a/src/lecode/providers/openai_compat.py b/src/lecode/providers/openai_compat.py index abf1382..c72c7bc 100644 --- a/src/lecode/providers/openai_compat.py +++ b/src/lecode/providers/openai_compat.py @@ -9,7 +9,7 @@ import json from collections.abc import AsyncIterator -from typing import Any +from typing import Any, Literal import httpx @@ -28,9 +28,46 @@ #: HTTP statuses that justify an automatic retry. RETRYABLE_STATUSES = frozenset({408, 409, 429, 500, 502, 503, 504}) +# Known structured codes. Never infer a failure category from message text. +_SYMBOLIC_STATUSES = { + "authentication": 401, + "invalid_api_key": 401, + "payment_required": 402, + "insufficient_quota": 402, + "credit_balance_exhausted": 402, + "model_not_found": 404, + "not_found": 404, + "rate_limit_exceeded": 429, + "provider_overloaded": 503, + "provider_unavailable": 502, + "server": 500, + "server_error": 500, + "timeout": 408, +} + +type ErrorCategory = Literal[ + "authentication", "budget", "model_not_found", "rate_limit", "upstream", "stream" +] + + +def _category_from_status(status: int | None) -> ErrorCategory | None: + if status is None: + return None + categories: dict[int, ErrorCategory] = { + 401: "authentication", + 402: "budget", + 404: "model_not_found", + 429: "rate_limit", + } + if status in categories: + return categories[status] + if status in {408, 409} or 500 <= status < 600: + return "upstream" + return None + class ProviderError(Exception): - """A provider failure with retry classification.""" + """A provider failure with a semantic category and retry classification.""" def __init__( self, @@ -39,47 +76,62 @@ def __init__( status: int | None = None, body: str = "", retryable: bool = False, + category: ErrorCategory | None = None, ) -> None: super().__init__(message) self.status = status self.body = body - self.retryable = retryable + self.category = category or _category_from_status(status) + if self.category is None and status is None and retryable: + self.category = "upstream" + self.retryable = retryable and self.category != "budget" -def _error_from_response(status: int, body: str) -> ProviderError: - """Map a non-2xx response to a :class:`ProviderError`. +def _provider_error( + error: Any, *, status: int | None, body: str, stream: bool = False +) -> ProviderError: + """Classify HTTP and SSE failures using the same structured fields.""" + error = error if isinstance(error, dict) else {} + metadata = error.get("metadata") + metadata = metadata if isinstance(metadata, dict) else {} + semantic_status = None + for value in (metadata.get("error_type"), error.get("code"), error.get("type")): + if isinstance(value, str) and value in _SYMBOLIC_STATUSES: + semantic_status = _SYMBOLIC_STATUSES[value] + break - Understands OpenRouter-style ``{"error": {"message", "code"}}`` bodies. - """ - message = body or f"HTTP {status}" - try: - data = json.loads(body) - error = data.get("error") if isinstance(data, dict) else None - if isinstance(error, dict) and error.get("message"): - message = str(error["message"]) - except (json.JSONDecodeError, AttributeError): - pass + code = error.get("code") + if isinstance(code, str) and len(code) == 3 and code.isascii() and code.isdecimal(): + code = int(code) + if status is None: + status = code if type(code) is int and 400 <= code < 600 else semantic_status + effective_status = semantic_status or status + category = _category_from_status(effective_status) + message = error.get("message") or ( + "provider stream error" if stream else body or f"HTTP {status}" + ) return ProviderError( - message, + str(message), status=status, body=body, retryable=status in RETRYABLE_STATUSES, + category=category or ("stream" if stream else None), ) +def _error_from_response(status: int, body: str) -> ProviderError: + """Map a non-2xx response to a :class:`ProviderError`.""" + try: + data = json.loads(body) + except json.JSONDecodeError: + data = None + error = data.get("error") if isinstance(data, dict) else None + return _provider_error(error, status=status, body=body) + + def _error_from_stream_chunk(chunk: dict[str, Any]) -> ProviderError: """Map an in-stream ``{"error": ...}`` event to a :class:`ProviderError`.""" - error = chunk.get("error") - error = error if isinstance(error, dict) else {} - message = str(error.get("message") or "provider stream error") - code = error.get("code") - status = code if isinstance(code, int) else None - return ProviderError( - message, - status=status, - body=json.dumps(chunk), - retryable=status in RETRYABLE_STATUSES if status else False, - ) + return _provider_error(chunk.get("error"), status=None, body=json.dumps(chunk), stream=True) async def _iter_sse_data(response: httpx.Response) -> AsyncIterator[str]: @@ -199,34 +251,89 @@ async def stream_chat( async for data in _iter_sse_data(response): if data.strip() == "[DONE]": break - chunk = json.loads(data) - if isinstance(chunk, dict) and "error" in chunk: - raise _error_from_stream_chunk(chunk) - usage = chunk.get("usage") - if usage: - # OpenRouter's usage extension reports the real billed - # amount as ``cost``; normalize to our ``cost_usd``. - if "cost" in usage and "cost_usd" not in usage: - usage["cost_usd"] = usage["cost"] - yield Usage(usage=usage) - for choice in chunk.get("choices") or []: - delta = choice.get("delta") or {} - content = delta.get("content") - if content: - yield TokenDelta(text=content) - reasoning = delta.get("reasoning_content") or delta.get("reasoning") - if reasoning: - yield ReasoningDelta(text=reasoning) - for tool_call in delta.get("tool_calls") or []: - function = tool_call.get("function") or {} - yield ToolCallDelta( - index=tool_call.get("index", 0), - id=tool_call.get("id") or "", - name=function.get("name") or "", - arguments_chunk=function.get("arguments") or "", - ) - if choice.get("finish_reason"): - finish_reason = choice["finish_reason"] + try: + chunk = json.loads(data) + if not isinstance(chunk, dict): + raise TypeError("stream event must be an object") + if "error" in chunk: + raise _error_from_stream_chunk(chunk) + usage = chunk.get("usage") + if usage is not None and not isinstance(usage, dict): + raise TypeError("stream usage must be an object") + if usage: + for field in ( + "input_tokens", + "output_tokens", + "prompt_tokens", + "completion_tokens", + ): + if usage.get(field) is not None: + int(usage[field]) + for field in ("cost", "cost_usd"): + if usage.get(field) is not None: + float(usage[field]) + # OpenRouter reports the real billed amount as ``cost``. + if "cost" in usage and "cost_usd" not in usage: + usage["cost_usd"] = usage["cost"] + yield Usage(usage=usage) + choices = chunk.get("choices") + if choices is not None and not isinstance(choices, list): + raise TypeError("stream choices must be a list") + for choice in choices or []: + delta = choice.get("delta") + if delta is not None and not isinstance(delta, dict): + raise TypeError("stream delta must be an object") + delta = delta or {} + content = delta.get("content") + reasoning_content = delta.get("reasoning_content") + reasoning = delta.get("reasoning") + finish = choice.get("finish_reason") + if any( + value is not None and not isinstance(value, str) + for value in (content, reasoning_content, reasoning, finish) + ): + raise TypeError("stream text must be a string") + if finish == "error": + raise ProviderError("provider stream error", category="stream") + reasoning = reasoning_content or reasoning + if content: + yield TokenDelta(text=content) + if reasoning: + yield ReasoningDelta(text=reasoning) + tool_calls = delta.get("tool_calls") + if tool_calls is not None and not isinstance(tool_calls, list): + raise TypeError("stream tool calls must be a list") + for tool_call in tool_calls or []: + function = tool_call.get("function") + if function is not None and not isinstance(function, dict): + raise TypeError("stream function must be an object") + function = function or {} + index = tool_call.get("index", 0) + if ( + type(index) is not int + or index < 0 + or any( + value is not None and not isinstance(value, str) + for value in ( + tool_call.get("id"), + function.get("name"), + function.get("arguments"), + ) + ) + ): + raise TypeError("invalid stream tool call") + yield ToolCallDelta( + index=index, + id=tool_call.get("id") or "", + name=function.get("name") or "", + arguments_chunk=function.get("arguments") or "", + ) + if finish: + finish_reason = finish + except (ValueError, AttributeError, TypeError, OverflowError) as e: + raise ProviderError( + "malformed provider stream event", category="stream" + ) from e except httpx.TransportError as e: raise ProviderError(str(e), retryable=True) from e yield Done(finish_reason=finish_reason) diff --git a/tests/test_headless.py b/tests/test_headless.py index e16d910..d699e4f 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -9,11 +9,12 @@ from pathlib import Path import pytest +import respx from tests.fakes import FakeProvider from typer.testing import CliRunner -from lecode.cli import EXIT_ERROR, EXIT_MAX_TURNS, EXIT_OK, EXIT_STARTUP, app -from lecode.providers.openai_compat import ProviderError +from lecode.cli import EXIT_MAX_TURNS, EXIT_OK, EXIT_STARTUP, app +from lecode.providers.openai_compat import ChatClient, ProviderError runner = CliRunner() @@ -23,8 +24,16 @@ def headless(tmp_path, monkeypatch): """Isolate config/session dirs, stub the dep check, return a script setter.""" monkeypatch.setenv("LECODE_CONFIG_DIR", str(tmp_path / "cfg")) monkeypatch.setenv("LECODE_SKILLS_DIR", str(tmp_path / "global-skills")) + monkeypatch.chdir(tmp_path) + (tmp_path / "cfg").mkdir() + (tmp_path / "cfg" / "config.toml").write_text("[mcp]\nenable_exa = false\n") monkeypatch.setattr("lecode.cli.find_missing_binaries", lambda: []) + async def no_sleep(delay): + pass + + monkeypatch.setattr("lecode.providers.retry.sleep", no_sleep) + def use_script(script: list[dict]) -> FakeProvider: provider = FakeProvider(script) monkeypatch.setattr("lecode.cli.build_provider", lambda config, api_key=None: provider) @@ -33,6 +42,17 @@ def use_script(script: list[dict]) -> FakeProvider: return tmp_path, use_script +@pytest.fixture(params=["prompt", "loop", "chain"]) +def headless_args(headless, request): + tmp_path, _ = headless + (tmp_path / "plan.md").write_text("- [ ] task\n") + return { + "prompt": ["-p", "hello"], + "loop": ["--loop", "plan.md"], + "chain": ["--chain", "hello"], + }[request.param] + + def test_headless_prints_final_text_and_cost(headless): _, use_script = headless use_script([{"text": "final answer", "usage": {"input_tokens": 10, "output_tokens": 5}}]) @@ -92,15 +112,67 @@ def test_headless_creates_auto_named_session(headless): assert roles == ["user", "assistant"] -def test_headless_provider_error_exits_1(headless): +@pytest.mark.parametrize( + ("status", "retryable", "exit_code"), + [ + (401, False, 4), + (402, False, 5), + (404, False, 6), + (429, False, 7), + (408, False, 8), + (409, False, 8), + (500, False, 8), + (501, False, 8), + (None, True, 8), + (400, False, 1), + (403, False, 1), + (None, False, 1), + ], +) +def test_provider_failure_exit_codes_in_all_modes( + headless, headless_args, status, retryable, exit_code +): _, use_script = headless - use_script([{"error": ProviderError("invalid api key", status=401)}]) + error = ProviderError("provider failed", status=status, retryable=retryable) + use_script([{"error": error}] * 5) + result = runner.invoke(app, headless_args) - result = runner.invoke(app, ["-p", "hello"]) + assert result.exit_code == exit_code + assert result.stdout == "" + assert "provider failed" in result.stderr + + +@pytest.mark.parametrize( + ("payload", "exit_code", "attempts"), + [ + ('{"error": {"code": "401"}}', 4, 1), + ('{"error": {"code": "402"}}', 5, 1), + ('{"error": {"code": "model_not_found"}}', 6, 1), + ('{"error": {"code": "429"}}', 7, 5), + ('{"error": {"code": "server_error"}}', 8, 5), + ('{"error": {"code": "insufficient_quota"}}', 5, 1), + ('{"error": {"code": "unknown"}}', 9, 1), + ("invalid json", 9, 1), + ], +) +@respx.mock +def test_stream_failure_exit_codes_and_retries_in_all_modes( + headless_args, monkeypatch, payload, exit_code, attempts +): + base_url = "https://api.test/v1" + monkeypatch.setattr( + "lecode.cli.build_provider", lambda config, api_key=None: ChatClient(base_url) + ) + respx.get(f"{base_url}/models").respond(200, json={"data": []}) + route = respx.post(f"{base_url}/chat/completions").respond( + 200, content=f"data: {payload}\n\n".encode() + ) + + result = runner.invoke(app, headless_args) - assert result.exit_code == EXIT_ERROR + assert result.exit_code == exit_code, result.output assert result.stdout == "" - assert "invalid api key" in result.stderr + assert route.call_count == attempts def test_headless_max_turns_exits_3(headless): diff --git a/tests/test_provider_client.py b/tests/test_provider_client.py index 2974a8a..f88a600 100644 --- a/tests/test_provider_client.py +++ b/tests/test_provider_client.py @@ -9,6 +9,7 @@ import respx from lecode.providers.openai_compat import ChatClient, ProviderError +from lecode.providers.retry import retry_async from lecode.providers.types import Done, TokenDelta, ToolCallDelta, Usage, collect BASE = "https://api.test/v1" @@ -171,9 +172,15 @@ async def test_interleaved_tool_calls_accumulate_by_index(): ] +@pytest.mark.parametrize( + "usage", + [ + {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + {"input_tokens": "10", "output_tokens": "5", "cost_usd": "0.25"}, + ], +) @respx.mock -async def test_usage_captured_from_final_chunk(): - usage = {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} +async def test_usage_captured_from_final_chunk(usage): body = _sse(_chunk({"content": "ok"}, "stop"), _chunk(usage=usage), "data: [DONE]") _sse_route(body) async with ChatClient(BASE) as client: @@ -265,6 +272,127 @@ async def test_midstream_error_event_raises(): assert "upstream exploded" in str(excinfo.value) +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize( + ("code", "metadata", "status", "category", "retryable"), + [ + ("401", None, 401, "authentication", False), + ("402", None, 402, "budget", False), + ("404", None, 404, "model_not_found", False), + ("429", None, 429, "rate_limit", True), + ("502", None, 502, "upstream", True), + ("server_error", None, 500, "upstream", True), + ("invalid_api_key", None, 401, "authentication", False), + ("model_not_found", None, 400, "model_not_found", False), + ("insufficient_quota", None, 429, "budget", False), + ("credit_balance_exhausted", None, 429, "budget", False), + (500, {"error_type": "authentication"}, 500, "authentication", True), + ("server_error", {"error_type": "payment_required"}, 500, "budget", False), + (429, {"error_type": "not_found"}, 429, "model_not_found", True), + (429, {"error_type": "payment_required"}, 429, "budget", False), + (502, {"error_type": "provider_overloaded"}, 502, "upstream", True), + ("429", [], 429, "rate_limit", True), + ], +) +@respx.mock +async def test_http_and_stream_errors_share_classification( + stream, code, metadata, status, category, retryable +): + error = {"code": code, "message": "provider failed", "metadata": metadata} + if stream: + _sse_route(_sse("data: " + json.dumps({"error": error}))) + else: + respx.post(CHAT_URL).respond(status, json={"error": error}) + async with ChatClient(BASE) as client: + with pytest.raises(ProviderError, match="provider failed") as excinfo: + await _collect_once(client) + assert excinfo.value.category == category + assert excinfo.value.retryable is retryable + if not stream: + assert excinfo.value.status == status + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("status", [400, 501]) +@respx.mock +async def test_semantic_codes_do_not_expand_numeric_retry_policy(stream, status): + error = ( + {"code": status, "metadata": {"error_type": "server"}} + if stream + else {"code": "server_error"} + ) + if stream: + _sse_route(_sse("data: " + json.dumps({"error": error}))) + else: + respx.post(CHAT_URL).respond(status, json={"error": error}) + async with ChatClient(BASE) as client: + with pytest.raises(ProviderError) as excinfo: + await _collect_once(client) + assert excinfo.value.status == status + assert excinfo.value.category == "upstream" + assert excinfo.value.retryable is False + + +@pytest.mark.parametrize( + "payload", + [ + '{"error": {"code": "unknown"}}', + '{"error": {"code": []}}', + '{"error": {"code": true}}', + '{"error": {"code": 429.0}}', + '{"error": {"code": "\uff14\uff12\uff19"}}', + '{"error": []}', + '{"error": null}', + "{invalid json", + "[]", + "null", + '{"choices": [null]}', + '{"choices": {}}', + '{"usage": [1]}', + '{"usage": {"prompt_tokens": "invalid"}}', + '{"usage": {"cost_usd": "invalid"}}', + '{"choices": [{"delta": {"content": 1}}]}', + '{"choices": [{"delta": {"reasoning": [1]}}]}', + '{"choices": [{"delta": {"reasoning_content": []}}]}', + '{"choices": [{"delta": {"reasoning_content": false}}]}', + '{"choices": [{"delta": {"tool_calls": [{"index": []}]}}]}', + '{"choices": [{"finish_reason": "error"}]}', + ], +) +@respx.mock +async def test_unknown_or_malformed_stream_errors_are_not_retried(payload): + _sse_route(_sse("data: " + payload)) + async with ChatClient(BASE) as client: + with pytest.raises(ProviderError) as excinfo: + await _collect_once(client) + assert excinfo.value.category == "stream" + assert excinfo.value.retryable is False + + +@pytest.mark.parametrize("code", ["429", "server_error"]) +@respx.mock +async def test_string_coded_stream_error_retries_and_recovers(monkeypatch, code): + async def no_sleep(delay): + pass + + monkeypatch.setattr("lecode.providers.retry.sleep", no_sleep) + first = _sse( + _chunk({"content": "discard this partial answer"}), + "data: " + json.dumps({"error": {"code": code}}), + ) + second = _sse(_chunk({"content": "recovered"}, "stop"), "data: [DONE]") + route = respx.post(CHAT_URL).mock( + side_effect=[ + httpx.Response(200, content=_aiter(first)), + httpx.Response(200, content=_aiter(second)), + ] + ) + async with ChatClient(BASE) as client: + result = await retry_async(lambda: _collect_once(client)) + assert result.content == "recovered" + assert route.call_count == 2 + + @respx.mock async def test_transport_error_is_retryable(): respx.post(CHAT_URL).mock(side_effect=httpx.ConnectError("connection refused")) From 5e0067d2d86512699ee07478c5f238e971ea9056 Mon Sep 17 00:00:00 2001 From: Mouhand-Kaddo Date: Tue, 29 Sep 2026 10:47:17 +0400 Subject: [PATCH 2/3] fix: classify malformed response encodings as stream failures --- src/lecode/providers/openai_compat.py | 2 ++ tests/test_provider_client.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/lecode/providers/openai_compat.py b/src/lecode/providers/openai_compat.py index c72c7bc..d8095e0 100644 --- a/src/lecode/providers/openai_compat.py +++ b/src/lecode/providers/openai_compat.py @@ -334,6 +334,8 @@ async def stream_chat( raise ProviderError( "malformed provider stream event", category="stream" ) from e + except httpx.DecodingError as e: + raise ProviderError("malformed provider stream encoding", category="stream") from e except httpx.TransportError as e: raise ProviderError(str(e), retryable=True) from e yield Done(finish_reason=finish_reason) diff --git a/tests/test_provider_client.py b/tests/test_provider_client.py index f88a600..128eaf1 100644 --- a/tests/test_provider_client.py +++ b/tests/test_provider_client.py @@ -393,6 +393,24 @@ async def no_sleep(delay): assert route.call_count == 2 +@respx.mock +async def test_malformed_compressed_stream_is_not_retried(): + def handler(request): + return httpx.Response( + 200, + content=_aiter(b"not-a-gzip-stream"), + headers={"content-type": "text/event-stream", "content-encoding": "gzip"}, + ) + + route = respx.post(CHAT_URL).mock(side_effect=handler) + async with ChatClient(BASE) as client: + with pytest.raises(ProviderError) as excinfo: + await retry_async(lambda: _collect_once(client)) + assert excinfo.value.category == "stream" + assert excinfo.value.retryable is False + assert route.call_count == 1 + + @respx.mock async def test_transport_error_is_retryable(): respx.post(CHAT_URL).mock(side_effect=httpx.ConnectError("connection refused")) From ef59be554796bb53890c38ade2ec2c44a409cf94 Mon Sep 17 00:00:00 2001 From: Mouhand-Kaddo Date: Tue, 29 Sep 2026 12:33:54 +0400 Subject: [PATCH 3/3] test(cli): reuse the headless fixture config directory --- tests/test_headless.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_headless.py b/tests/test_headless.py index d699e4f..cca275a 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -72,7 +72,7 @@ def test_headless_thinking_reaches_model_request(headless, monkeypatch, level): tmp_path, use_script = headless monkeypatch.chdir(tmp_path) config_path = tmp_path / "cfg" / "config.toml" - config_path.parent.mkdir() + config_path.parent.mkdir(exist_ok=True) original = 'schema_version = 1\n\n[llm]\nthinking = "high"\n' config_path.write_text(original) provider = use_script([{"text": "ok"}])