From 20fad4ab90d66b2f5b7b4e3f700d9c542f7d3fec Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Sun, 17 May 2026 14:36:45 -0700 Subject: [PATCH 1/7] feat(exceptions): expose response headers on RoeAPIException for Retry-After parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoeAPIException now carries response headers so callers can read Retry-After on 429s symmetrically with the raw-httpx path. Today the SDK discards headers entirely in translate_response, forcing downstream consumers (e.g. roe-mcp) to hardcode retry_after=1.0 because there's no machine-readable signal available — even though roe-main correctly emits the Retry-After header on its DRF-throttled responses. Changes: - src/roe/exceptions.py: add headers: Mapping[str, str] | None = None to RoeAPIException.__init__; store as a plain dict copy. - src/roe/exceptions.py: translate_response() pulls getattr(response, "headers", None) and threads it into the raised exception. Works for both httpx.Response (Headers) and roe._generated.types.Response (MutableMapping[str, str]). - tests/unit/test_translate_response.py: 6 new tests pinning the contract — Retry-After (numeric + HTTP-date), missing header, general header preservation on 404/500, and stub-without-headers safety. - pyproject.toml: version bumped 1.0.80 -> 1.0.801 (skip past the natural patch sequence so this doesn't collide with the next auto-bump from roe-main). Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- src/roe/exceptions.py | 6 +- tests/unit/test_translate_response.py | 155 ++++++++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_translate_response.py diff --git a/pyproject.toml b/pyproject.toml index 3610e90..ecc87c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "roe-ai" -version = "1.0.80" +version = "1.0.801" authors = [ { name = "Roe AI", email = "founders@roe-ai.com" }, ] diff --git a/src/roe/exceptions.py b/src/roe/exceptions.py index 51b1c00..a7b8693 100644 --- a/src/roe/exceptions.py +++ b/src/roe/exceptions.py @@ -3,6 +3,7 @@ from __future__ import annotations import json as _json +from collections.abc import Mapping from http import HTTPStatus from typing import Any @@ -15,11 +16,13 @@ def __init__( message: str, status_code: int | None = None, response: dict[str, Any] | None = None, + headers: Mapping[str, str] | None = None, ): super().__init__(message) self.message = message self.status_code = status_code self.response = response + self.headers = dict(headers) if headers is not None else None class BadRequestError(RoeAPIException): @@ -124,5 +127,6 @@ def translate_response(response: Any) -> None: snippet = content_str[:200] if content_str else "" message = f"HTTP {status_code}: {snippet}" if snippet else f"HTTP {status_code}" + raw_headers = getattr(response, "headers", None) cls = get_exception_for_status_code(status_code) - raise cls(message=message, status_code=status_code, response=error_data) + raise cls(message=message, status_code=status_code, response=error_data, headers=raw_headers) diff --git a/tests/unit/test_translate_response.py b/tests/unit/test_translate_response.py new file mode 100644 index 0000000..7cce897 --- /dev/null +++ b/tests/unit/test_translate_response.py @@ -0,0 +1,155 @@ +"""Verify translate_response maps status codes to typed exceptions.""" + +from __future__ import annotations + +import os +import sys +from http import HTTPStatus + +import httpx +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + +from roe.exceptions import ( # noqa: E402 + AuthenticationError, + BadRequestError, + ForbiddenError, + InsufficientCreditsError, + NotFoundError, + RoeAPIException, + ServerError, + translate_response, +) + + +def _resp(status: int, content: bytes = b"") -> httpx.Response: + return httpx.Response(status, content=content) + + +def test_2xx_is_noop(): + translate_response(_resp(200, b'{"ok": true}')) + translate_response(_resp(204)) + + +@pytest.mark.parametrize( + "status,exc_type", + [ + (400, BadRequestError), + (401, AuthenticationError), + (402, InsufficientCreditsError), + (403, ForbiddenError), + (404, NotFoundError), + (429, RoeAPIException), + (500, ServerError), + (502, ServerError), + ], +) +def test_status_codes_map_to_typed_exceptions(status, exc_type): + with pytest.raises(exc_type) as exc_info: + translate_response(_resp(status, b'{"detail": "boom"}')) + assert exc_info.value.status_code == status + assert exc_info.value.message == "boom" + + +def test_dict_body_extracts_detail(): + with pytest.raises(BadRequestError) as exc_info: + translate_response(_resp(400, b'{"detail": "bad"}')) + assert exc_info.value.message == "bad" + assert exc_info.value.response == {"detail": "bad"} + + +def test_list_body_joined_as_message(): + with pytest.raises(BadRequestError) as exc_info: + translate_response(_resp(400, b'["a", "b"]')) + assert exc_info.value.message == "a; b" + assert exc_info.value.response is None + + +def test_non_json_body_falls_back_to_status_snippet(): + with pytest.raises(NotFoundError) as exc_info: + translate_response(_resp(404, b"not found")) + assert "404" in exc_info.value.message + + +def test_empty_body(): + with pytest.raises(NotFoundError) as exc_info: + translate_response(_resp(404)) + assert exc_info.value.message == "HTTP 404" + + +def test_accepts_generated_response_shape(): + """Response from openapi-python-client uses HTTPStatus + bytes content.""" + + class FakeResp: + status_code = HTTPStatus(403) + content = b'{"error": "forbidden"}' + + with pytest.raises(ForbiddenError) as exc_info: + translate_response(FakeResp()) + assert exc_info.value.message == "forbidden" + + +def test_dict_body_uses_error_key_when_no_detail(): + with pytest.raises(BadRequestError) as exc_info: + translate_response(_resp(400, b'{"error": "bad input"}')) + assert exc_info.value.message == "bad input" + + +def test_dict_body_uses_message_key_as_fallback(): + with pytest.raises(BadRequestError) as exc_info: + translate_response(_resp(400, b'{"message": "broken"}')) + assert exc_info.value.message == "broken" + + +def _resp_with_headers(status: int, headers: dict[str, str], content: bytes = b'{"detail":"x"}') -> httpx.Response: + return httpx.Response(status, headers=headers, content=content) + + +def test_429_retry_after_seconds_preserved_in_headers(): + with pytest.raises(RoeAPIException) as exc_info: + translate_response(_resp_with_headers(429, {"Retry-After": "7"})) + assert exc_info.value.headers is not None + assert exc_info.value.headers.get("retry-after") == "7" + + +def test_429_retry_after_http_date_preserved_in_headers(): + date = "Wed, 21 Oct 2025 07:28:00 GMT" + with pytest.raises(RoeAPIException) as exc_info: + translate_response(_resp_with_headers(429, {"Retry-After": date})) + assert exc_info.value.headers.get("retry-after") == date + + +def test_429_no_retry_after_header_yields_empty_map(): + with pytest.raises(RoeAPIException) as exc_info: + translate_response(_resp(429, b'{"detail":"throttled"}')) + assert exc_info.value.headers is not None + assert "retry-after" not in exc_info.value.headers + + +def test_404_headers_preserved_general_feature(): + with pytest.raises(NotFoundError) as exc_info: + translate_response(_resp_with_headers(404, {"x-request-id": "abc-123"})) + assert exc_info.value.headers.get("x-request-id") == "abc-123" + + +def test_500_headers_preserved_general_feature(): + with pytest.raises(ServerError) as exc_info: + translate_response(_resp_with_headers(500, {"x-trace": "t1"})) + assert exc_info.value.headers.get("x-trace") == "t1" + + +def test_response_without_headers_attribute_yields_none_headers(): + """A stub response that omits .headers should not crash; exc.headers becomes None.""" + + class Stub: + status_code = 500 + content = b'{"detail": "x"}' + + with pytest.raises(ServerError) as exc_info: + translate_response(Stub()) + assert exc_info.value.headers is None + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) diff --git a/uv.lock b/uv.lock index c783348..eda1dc1 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "roe-ai" -version = "1.0.80" +version = "1.0.801" source = { editable = "." } dependencies = [ { name = "attrs" }, From 48299615d87a6f3e7c8c0f98b9c0ac1dd0c80878 Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Sun, 17 May 2026 14:42:35 -0700 Subject: [PATCH 2/7] fix(exceptions): lowercase-normalise headers + rename misleading test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Greptile P1 + P2 on #38: * RoeAPIException.__init__ now normalises header keys to lowercase at store time so `.get("retry-after")` works on both the httpx path (which already lowercases on iteration) and the generated-client MutableMapping path (which preserves original "Retry-After" casing). Without this, callers using the generated client would silently miss Retry-After even though the server sent it. * New test test_generated_response_with_uppercased_headers_normalised_to_lowercase pins the contract by passing a stub with original-cased headers and asserting lowercase lookup succeeds. * Rename test_429_no_retry_after_header_yields_empty_map -> test_429_no_retry_after_header_when_absent. httpx always synthesises content-length etc., so the dict is not actually empty — only the absence of retry-after is meaningful. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/roe/exceptions.py | 9 ++++++++- tests/unit/test_translate_response.py | 21 ++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/roe/exceptions.py b/src/roe/exceptions.py index a7b8693..d07af12 100644 --- a/src/roe/exceptions.py +++ b/src/roe/exceptions.py @@ -22,7 +22,14 @@ def __init__( self.message = message self.status_code = status_code self.response = response - self.headers = dict(headers) if headers is not None else None + # Normalise keys to lowercase so `.get("retry-after")` works regardless + # of which response type populated them: httpx.Headers iterates lowercased + # already, but the generated-client MutableMapping preserves original + # HTTP casing (e.g. "Retry-After"). Lowercasing at store time gives + # callers one consistent contract. + self.headers = ( + {k.lower(): v for k, v in headers.items()} if headers is not None else None + ) class BadRequestError(RoeAPIException): diff --git a/tests/unit/test_translate_response.py b/tests/unit/test_translate_response.py index 7cce897..adb6b3d 100644 --- a/tests/unit/test_translate_response.py +++ b/tests/unit/test_translate_response.py @@ -120,7 +120,9 @@ def test_429_retry_after_http_date_preserved_in_headers(): assert exc_info.value.headers.get("retry-after") == date -def test_429_no_retry_after_header_yields_empty_map(): +def test_429_no_retry_after_header_when_absent(): + """httpx still synthesises content-length etc., so the headers dict isn't + empty — but `retry-after` must not be present when the server didn't send it.""" with pytest.raises(RoeAPIException) as exc_info: translate_response(_resp(429, b'{"detail":"throttled"}')) assert exc_info.value.headers is not None @@ -139,6 +141,23 @@ def test_500_headers_preserved_general_feature(): assert exc_info.value.headers.get("x-trace") == "t1" +def test_generated_response_with_uppercased_headers_normalised_to_lowercase(): + """The generated client's MutableMapping[str, str] preserves original HTTP + casing (e.g. "Retry-After"). Callers must still be able to look up by + lowercase, so __init__ normalises at store time.""" + + class GeneratedRespStub: + status_code = HTTPStatus(429) + content = b'{"detail": "throttled"}' + headers = {"Retry-After": "9", "X-Trace-Id": "abc"} + + with pytest.raises(RoeAPIException) as exc_info: + translate_response(GeneratedRespStub()) + assert exc_info.value.headers is not None + assert exc_info.value.headers.get("retry-after") == "9" + assert exc_info.value.headers.get("x-trace-id") == "abc" + + def test_response_without_headers_attribute_yields_none_headers(): """A stub response that omits .headers should not crash; exc.headers becomes None.""" From 1406243692cb378f7db6a9b06c628106462722d5 Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Sun, 17 May 2026 22:18:10 -0700 Subject: [PATCH 3/7] chore(version): 1.0.801 -> 1.0.81 (avoid breaking the weekly auto-release) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier 1.0.801 bump (chosen to "skip past the natural patch sequence") had an unintended consequence: 1.0.801 > 1.0.81 in semver ordering. Once published, pip/npm/go-proxy always resolve to the highest semver, so future weekly releases (1.0.81, 1.0.82, ...) from roe-main's release-1-0-X branches would publish successfully but be permanently uninstallable — users would stay pinned to 1.0.801 forever even after subsequent releases. 1.0.81 is the natural next patch (current PyPI latest is 1.0.80) and keeps the weekly release pipeline working unchanged. Verified 1.0.81 is unclaimed on PyPI, npm, and the Go proxy. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ecc87c5..b6652ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "roe-ai" -version = "1.0.801" +version = "1.0.81" authors = [ { name = "Roe AI", email = "founders@roe-ai.com" }, ] From 1aa89656feb20276ba2334964634b70b056aa3f1 Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Sun, 17 May 2026 22:27:04 -0700 Subject: [PATCH 4/7] =?UTF-8?q?chore(version):=20revert=20pyproject=20bump?= =?UTF-8?q?=20=E2=80=94=20defer=20to=20next=20roe-main=20weekly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks back the manual 1.0.801 -> 1.0.81 bump on this branch and leaves the version at 1.0.80 (unchanged from main). Rationale: - roe-python is in active use; cleanest release path is to let roe-main's next weekly release-1-0-81 branch fire prepare-target for this repo, which will write 1.0.81 to pyproject.toml as part of the regular codegen-refresh PR. Our headers feature ships bundled into that natural 1.0.81 release with zero coordination. - Leaves tag-on-release-merge.yml a no-op on this PR's merge (pyproject.toml is unchanged from main, so no v* tag is cut, publish.yml doesn't fire). The exceptions.py + test changes sit on main until the weekly picks them up. - Preserves parity with roe-typescript (also reverted to 1.0.80). roe-go takes a different path — see roe-ai/roe-golang#14 commit message for why. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b6652ba..3610e90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "roe-ai" -version = "1.0.81" +version = "1.0.80" authors = [ { name = "Roe AI", email = "founders@roe-ai.com" }, ] From 3d47acd5db0067063c661a5cce2e745d4eac0d19 Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Sun, 17 May 2026 22:28:49 -0700 Subject: [PATCH 5/7] =?UTF-8?q?Revert=20"chore(version):=20revert=20pyproj?= =?UTF-8?q?ect=20bump=20=E2=80=94=20defer=20to=20next=20roe-main=20weekly"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restoring the 1.0.801 bump. Earlier flip-flop was my mistake — user intent all along is: Python + TS at 1.0.801 (manually published immediately on merge) Go at 1.0.81 (manually published immediately on merge) Next roe-main weekly (release-1-0-81-*) will: - Python/TS: write 1.0.81 to version files (a downgrade from 1.0.801). tag-on-release-merge fires v1.0.81 -> publishes 1.0.81 on PyPI / npm. Consumers with loose `>=` pins keep resolving 1.0.801 (PEP 440 / semver picks highest); consumers with exact `==1.0.81` pins get the natural weekly bundle. - Go: writes 1.0.81 (same value as already on main). No diff in VERSION, no tag, no new publish — a clean "dud" weekly for Go. This restores parity at 1.0.801 across Python + TS now and accepts the orphan-release behaviour for the patch range 1.0.81..1.0.800 (those weekly releases will publish but stay un-installed by loose pins until weekly version sequence catches up past 1.0.801). This reverts commit 1aa8965a92099ef9ec24f4af9f5d2a0eed2c0d9c. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3610e90..ecc87c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "roe-ai" -version = "1.0.80" +version = "1.0.801" authors = [ { name = "Roe AI", email = "founders@roe-ai.com" }, ] From d03d00db8acfef2c4e729e1027580105b4ff0f6a Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Sun, 17 May 2026 22:41:12 -0700 Subject: [PATCH 6/7] docs(readme): document headers field on RoeAPIException Adds a brief snippet right after the typed-exception section showing how to read Retry-After (and other response headers) off the exception, paired with a status_code==429 gate since Python's exception hierarchy doesn't have a dedicated RateLimitError class (429 raises bare RoeAPIException). Documents the lowercase-key contract callers need to know. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 8b9584c..b6fb00c 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,18 @@ except NotFoundError as exc: print(exc.status_code, exc.message) ``` +Every `RoeAPIException` also carries `exc.headers` (lowercase-keyed dict +of the upstream response headers). Use it to read `Retry-After` on 429s +or `X-Request-Id` for support tickets, without falling back to the raw +httpx layer: + +```python +except RoeAPIException as exc: + if exc.status_code == 429 and exc.headers: + retry_after = float(exc.headers.get("retry-after", "1")) + time.sleep(retry_after) +``` + `job.wait()` does not raise on agent-side failures — instead the returned result carries `result["status"] == JobStatus.FAILURE` and `result["error_message"]`. Transport / HTTP errors hit the typed From 3fc6652d55a21dd0248c9dbb724e98d66a20a3a7 Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Mon, 18 May 2026 00:57:32 -0700 Subject: [PATCH 7/7] docs(exceptions): clarify retry-after header format --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b6fb00c..97bcec1 100644 --- a/README.md +++ b/README.md @@ -90,13 +90,13 @@ except NotFoundError as exc: Every `RoeAPIException` also carries `exc.headers` (lowercase-keyed dict of the upstream response headers). Use it to read `Retry-After` on 429s or `X-Request-Id` for support tickets, without falling back to the raw -httpx layer: +httpx layer. `Retry-After` is preserved exactly as sent, so it may be +numeric seconds or an HTTP-date: ```python except RoeAPIException as exc: if exc.status_code == 429 and exc.headers: - retry_after = float(exc.headers.get("retry-after", "1")) - time.sleep(retry_after) + retry_after = exc.headers.get("retry-after") ``` `job.wait()` does not raise on agent-side failures — instead the returned