From e610799854fa700adb33e5f06344b774a7677647 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 21 Oct 2026 21:53:25 +0300 Subject: [PATCH 1/3] feat(errors): expose Retry-After seconds on rate-limit errors NorbixError and every subclass (RateLimitError included) take an optional retry_after_seconds keyword and keep it as an attribute. The sync and async transports read the answer's Retry-After header through the new errors.retry_after_seconds_of (delta seconds, or seconds until an HTTP date, never below 0; None when absent or unreadable) and pass it to error_from_body. Co-Authored-By: Claude Opus 5.5 --- src/norbix_python/api/logs.py | 3 +- src/norbix_python/errors.py | 60 ++++++++++++++++++++++++++++++++-- src/norbix_python/transport.py | 16 +++++++-- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/norbix_python/api/logs.py b/src/norbix_python/api/logs.py index 047d690..9497cd0 100644 --- a/src/norbix_python/api/logs.py +++ b/src/norbix_python/api/logs.py @@ -38,7 +38,8 @@ def write_logs( Raises ``ValidationError`` (400) for a broken entry or ``CM-ERRORS-LOGGING-002`` when the Logs module is off, and ``RateLimitError`` (429, ``CM-ERRORS-LOGGING-003``) when the project's - per-minute quota is used up. The call is not retried. + per-minute quota is used up — its ``retry_after_seconds`` says how many + seconds to wait. The call is not retried. """ return self._transport.send( target="api", diff --git a/src/norbix_python/errors.py b/src/norbix_python/errors.py index 2ae96cc..23a9d56 100644 --- a/src/norbix_python/errors.py +++ b/src/norbix_python/errors.py @@ -1,5 +1,8 @@ from __future__ import annotations +import math +import time +from email.utils import parsedate_to_datetime from typing import Any @@ -39,6 +42,7 @@ def __init__( errors: list[ErrorItem] | None = None, body: Any = None, context: dict[str, Any] | None = None, + retry_after_seconds: int | None = None, ) -> None: super().__init__(message) self.message = message @@ -52,6 +56,12 @@ def __init__( #: Extra facts about the error the message came from — the missing #: permission, the reference id of an internal error. Empty when none. self.context = context or {} + #: Seconds to wait before sending again, from the answer's + #: ``Retry-After`` header: the number itself, or the seconds until the + #: HTTP date it names (never below 0). ``None`` when the header is + #: absent or unreadable. A 429 ``CM-ERRORS-LOGGING-003`` from + #: ``write_logs`` carries it. + self.retry_after_seconds = retry_after_seconds @property def http_status(self) -> int: @@ -80,6 +90,7 @@ def __init__( errors: list[ErrorItem] | None = None, body: Any = None, context: dict[str, Any] | None = None, + retry_after_seconds: int | None = None, ) -> None: super().__init__( message, @@ -89,6 +100,7 @@ def __init__( errors=errors, body=body, context=context, + retry_after_seconds=retry_after_seconds, ) @@ -105,6 +117,7 @@ def __init__( errors: list[ErrorItem] | None = None, body: Any = None, context: dict[str, Any] | None = None, + retry_after_seconds: int | None = None, ) -> None: super().__init__( message, @@ -114,6 +127,7 @@ def __init__( errors=errors, body=body, context=context, + retry_after_seconds=retry_after_seconds, ) @@ -130,6 +144,7 @@ def __init__( errors: list[ErrorItem] | None = None, body: Any = None, context: dict[str, Any] | None = None, + retry_after_seconds: int | None = None, ) -> None: super().__init__( message, @@ -139,6 +154,7 @@ def __init__( errors=errors, body=body, context=context, + retry_after_seconds=retry_after_seconds, ) @@ -155,6 +171,7 @@ def __init__( errors: list[ErrorItem] | None = None, body: Any = None, context: dict[str, Any] | None = None, + retry_after_seconds: int | None = None, ) -> None: super().__init__( message, @@ -164,6 +181,7 @@ def __init__( errors=errors, body=body, context=context, + retry_after_seconds=retry_after_seconds, ) @@ -176,6 +194,7 @@ def error_from_http( errors: list[ErrorItem] | None = None, body: Any = None, context: dict[str, Any] | None = None, + retry_after_seconds: int | None = None, ) -> NorbixError: kwargs: dict[str, Any] = { "status": status, @@ -184,6 +203,7 @@ def error_from_http( "errors": errors, "body": body, "context": context, + "retry_after_seconds": retry_after_seconds, } if status == 404: return NotFoundError(message, **kwargs) @@ -251,7 +271,41 @@ def _context_of(value: Any) -> dict[str, Any] | None: return value if isinstance(value, dict) and value else None -def error_from_body(*, body: Any, status: int, text: str = "") -> NorbixError: +def retry_after_seconds_of(value: str | None, *, now: float | None = None) -> int | None: + """A ``Retry-After`` header value as whole seconds. + + The number itself, or the seconds from ``now`` (default: the current time) + until the HTTP date it names, never below 0. ``None`` when the value is + absent or unreadable. + """ + if value is None or not value.strip(): + return None + value = value.strip() + try: + seconds: float | None = float(value) + except ValueError: + seconds = None + if seconds is not None: + if not math.isfinite(seconds) or seconds < 0: + return None + return math.ceil(seconds) + try: + at = parsedate_to_datetime(value) + except (TypeError, ValueError, IndexError): + return None + if at.tzinfo is None: + return None + current = time.time() if now is None else now + return max(0, math.ceil(at.timestamp() - current)) + + +def error_from_body( + *, + body: Any, + status: int, + text: str = "", + retry_after_seconds: int | None = None, +) -> NorbixError: """Build the error a gateway answer describes. Each of message, code and context is taken from the first place that has @@ -266,7 +320,8 @@ def error_from_body(*, body: Any, status: int, text: str = "") -> NorbixError: ``Request failed (HTTP )`` and ``HTTP_`` are the last fallback, used when the body says nothing at all — a 502 page that is not JSON, say. Every entry of ``errors[]`` is kept in :attr:`NorbixError.errors` - and the body as it arrived in :attr:`NorbixError.body`. + and the body as it arrived in :attr:`NorbixError.body`. The transport passes + the answer's ``Retry-After`` as ``retry_after_seconds``. """ details: dict[str, Any] = body if isinstance(body, dict) else {} response_status = _response_status_of(body) or {} @@ -305,4 +360,5 @@ def error_from_body(*, body: Any, status: int, text: str = "") -> NorbixError: errors=items, body=body if body is not None else (text or None), context=context, + retry_after_seconds=retry_after_seconds, ) diff --git a/src/norbix_python/transport.py b/src/norbix_python/transport.py index 31af8e1..d613ad1 100644 --- a/src/norbix_python/transport.py +++ b/src/norbix_python/transport.py @@ -9,7 +9,7 @@ import httpx -from .errors import NorbixError, error_from_body, says_it_failed +from .errors import NorbixError, error_from_body, retry_after_seconds_of, says_it_failed HttpVerb = Literal["GET", "POST", "PUT", "PATCH", "DELETE"] # "optional": auth is sent when the client has a token, never required — used @@ -157,6 +157,7 @@ def send( body=parsed, status=response.status_code, text=response.text, + retry_after_seconds=retry_after_seconds_of(response.headers.get("retry-after")), ) # An endpoint that answers with a file, not a document. Those answers @@ -176,7 +177,11 @@ def send( # refusal with HTTP 200 and responseStatus.isSuccess = False, and that # is a failure the caller must see (10b-files, issue #67). if says_it_failed(parsed_ok): - raise error_from_body(body=parsed_ok, status=response.status_code) + raise error_from_body( + body=parsed_ok, + status=response.status_code, + retry_after_seconds=retry_after_seconds_of(response.headers.get("retry-after")), + ) return parsed_ok def _request_with_retries( @@ -323,6 +328,7 @@ async def send( body=parsed, status=response.status_code, text=response.text, + retry_after_seconds=retry_after_seconds_of(response.headers.get("retry-after")), ) # An endpoint that answers with a file, not a document. Those answers @@ -342,7 +348,11 @@ async def send( # refusal with HTTP 200 and responseStatus.isSuccess = False, and that # is a failure the caller must see (10b-files, issue #67). if says_it_failed(parsed_ok): - raise error_from_body(body=parsed_ok, status=response.status_code) + raise error_from_body( + body=parsed_ok, + status=response.status_code, + retry_after_seconds=retry_after_seconds_of(response.headers.get("retry-after")), + ) return parsed_ok async def _request_with_retries( From 6bedc35526668e8abb07d41a69823b28813c516c Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 21 Oct 2026 21:53:25 +0300 Subject: [PATCH 2/3] test(errors): Retry-After seconds on a write_logs 429, absent header, 500 429 with Retry-After: 37 gives 37 with CM-ERRORS-LOGGING-003 and one POST /logs (sync and async); no header and a 500 give None; an HTTP date gives the seconds until it; retry_after_seconds_of table test. Co-Authored-By: Claude Opus 5.5 --- tests/api/test_logs.py | 76 +++++++++++++++++++++++++++++++++++++++++- tests/test_errors.py | 47 ++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/tests/api/test_logs.py b/tests/api/test_logs.py index e483842..94b9975 100644 --- a/tests/api/test_logs.py +++ b/tests/api/test_logs.py @@ -7,7 +7,7 @@ import httpx import pytest -from norbix_python import AsyncNorbix, Norbix, RateLimitError +from norbix_python import AsyncNorbix, Norbix, NorbixError, RateLimitError from ..helpers import make_client @@ -92,3 +92,77 @@ async def run() -> object: assert result == {"accepted": 1} assert [(r.method, urlparse(str(r.url)).path) for r in seen] == [('POST', '/v3/logs')] assert json.loads(seen[0].content) == {"entries": [{"title": "t"}]} + + +_QUOTA_BODY = { + "responseStatus": { + "errorCode": "CM-ERRORS-LOGGING-003", + "message": "quota used up", + "errors": [{"errorCode": "CM-ERRORS-LOGGING-003", "message": "quota used up"}], + } +} + + +def _answering(status: int, headers: dict[str, str], body: object) -> tuple[Norbix, list[httpx.Request]]: + calls: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + return httpx.Response(status, headers=headers, json=body) + + client = Norbix( + project_id="test-project", + bearer_token="test-token", + http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + return client, calls + + +def test_api_logs_write_logs_quota_carries_retry_after_seconds() -> None: + client, calls = _answering(429, {"Retry-After": "37"}, _QUOTA_BODY) + with pytest.raises(RateLimitError) as info: + client.api.logs.write_logs(entries=[{"title": "t"}]) + assert info.value.error_code == "CM-ERRORS-LOGGING-003" + assert info.value.retry_after_seconds == 37 + assert [(r.method, urlparse(str(r.url)).path) for r in calls] == [("POST", "/v3/logs")] + + +def test_api_logs_write_logs_quota_without_retry_after_is_none() -> None: + client, calls = _answering(429, {}, _QUOTA_BODY) + with pytest.raises(RateLimitError) as info: + client.api.logs.write_logs(entries=[{"title": "t"}]) + assert info.value.retry_after_seconds is None + assert len(calls) == 1 + + +def test_api_logs_write_logs_500_has_no_retry_after() -> None: + client, calls = _answering(500, {}, {"responseStatus": {"errorCode": "CM-ERRORS-500", "message": "boom"}}) + with pytest.raises(NorbixError) as info: + client.api.logs.write_logs(entries=[{"title": "t"}]) + assert info.value.http_status == 500 + assert info.value.retry_after_seconds is None + assert len(calls) == 1 # a POST is not retried + + +def test_async_api_logs_write_logs_quota_carries_retry_after_seconds() -> None: + calls: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + return httpx.Response(429, headers={"Retry-After": "37"}, json=_QUOTA_BODY) + + async def run() -> None: + client = AsyncNorbix( + project_id="test-project", + bearer_token="test-token", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + try: + await client.api.logs.write_logs(entries=[{"title": "t"}]) + finally: + await client.aclose() + + with pytest.raises(RateLimitError) as info: + asyncio.run(run()) + assert info.value.retry_after_seconds == 37 + assert len(calls) == 1 diff --git a/tests/test_errors.py b/tests/test_errors.py index 65b80e1..a95da36 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -342,3 +342,50 @@ def test_an_empty_errors_list_falls_back_to_the_top_of_the_body() -> None: assert caught.value.error_code == "CM-ERRORS-INFRA-NORBIX-001" assert caught.value.context == {"ReferenceId": "ref_1"} + + +# Retry-After is delta seconds or an HTTP date; anything else is no value. +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("37", 37), + (" 0 ", 0), + ("1.2", 2), + ("Wed, 21 Oct 2026 08:01:30 GMT", 90), + ("Wed, 21 Oct 2026 07:00:00 GMT", 0), + (None, None), + ("", None), + ("soon", None), + ("-1", None), + ("nan", None), + ], +) +def test_retry_after_seconds_of(value: str | None, expected: int | None) -> None: + from datetime import datetime, timezone + + from norbix_python.errors import retry_after_seconds_of + + now = datetime(2026, 10, 21, 8, 0, 0, tzinfo=timezone.utc).timestamp() + assert retry_after_seconds_of(value, now=now) == expected + + +def test_a_429_with_an_http_date_gives_the_seconds_until_it() -> None: + from email.utils import format_datetime + from datetime import datetime, timedelta, timezone + + from norbix_python import RateLimitError + + at = format_datetime(datetime.now(timezone.utc) + timedelta(seconds=120), usegmt=True) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(429, headers={"Retry-After": at}, json={}) + + client = Norbix( + project_id="test-project", + bearer_token="test-token", + http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + with pytest.raises(RateLimitError) as info: + client.api.logs.write_logs(entries=[{"title": "t"}]) + assert info.value.retry_after_seconds is not None + assert 115 <= info.value.retry_after_seconds <= 121 From 57d674c98f8425d26726831c8ec4ecd2a324ae9c Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 21 Oct 2026 21:53:25 +0300 Subject: [PATCH 3/3] docs(errors): retry_after_seconds in Errors and the Logs page Co-Authored-By: Claude Opus 5.5 --- README.md | 15 +++++++++++++++ docs/api/logs.md | 13 +++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index deec163..8669f45 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,21 @@ when the body has no `responseStatus` are the top-level `message` and `HTTP_` is the last fallback, used when the body says nothing — a 500 page that is not JSON, say. +`retry_after_seconds` (`int | None`) is the answer's `Retry-After` header in +whole seconds — the number itself, or the seconds until the HTTP date it names +(never below 0); `None` when the header is absent or unreadable. A 429 +`RateLimitError` carries it, for example `CM-ERRORS-LOGGING-003` from +`api.logs.write_logs` when the project's per-minute log quota is used up: + +```python +from norbix_python import RateLimitError + +try: + norbix.api.logs.write_logs(entries=[{"title": "Order paid"}]) +except RateLimitError as exc: + time.sleep(exc.retry_after_seconds or 60) # then send the entries again +``` + ### Breaking change — a refused call now raises The gateway answers a business refusal (an unknown id, a rule that says no) diff --git a/docs/api/logs.md b/docs/api/logs.md index fc204e8..d17d679 100644 --- a/docs/api/logs.md +++ b/docs/api/logs.md @@ -53,5 +53,14 @@ itself. | 429 | `RateLimitError`, `CM-ERRORS-LOGGING-003` | the project's per-minute quota is used up | The SDK does not retry `write_logs` (it is a POST). On a 429 the gateway sends -a `Retry-After` header with the seconds to wait; the SDK does not expose -response headers on errors yet, so wait about a minute before you send again. +a `Retry-After` header with the seconds to wait; the SDK puts it on the error as +`retry_after_seconds` (`None` when the header is absent or unreadable): + +```python +from norbix_python import RateLimitError + +try: + norbix.api.logs.write_logs(entries=entries) +except RateLimitError as exc: + time.sleep(exc.retry_after_seconds or 60) # then send the entries again +```