Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,21 @@ when the body has no `responseStatus` are the top-level `message` and
`HTTP_<status>` 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)
Expand Down
13 changes: 11 additions & 2 deletions docs/api/logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
3 changes: 2 additions & 1 deletion src/norbix_python/api/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 58 additions & 2 deletions src/norbix_python/errors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

import math
import time
from email.utils import parsedate_to_datetime
from typing import Any


Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -89,6 +100,7 @@ def __init__(
errors=errors,
body=body,
context=context,
retry_after_seconds=retry_after_seconds,
)


Expand All @@ -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,
Expand All @@ -114,6 +127,7 @@ def __init__(
errors=errors,
body=body,
context=context,
retry_after_seconds=retry_after_seconds,
)


Expand All @@ -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,
Expand All @@ -139,6 +154,7 @@ def __init__(
errors=errors,
body=body,
context=context,
retry_after_seconds=retry_after_seconds,
)


Expand All @@ -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,
Expand All @@ -164,6 +181,7 @@ def __init__(
errors=errors,
body=body,
context=context,
retry_after_seconds=retry_after_seconds,
)


Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -266,7 +320,8 @@ def error_from_body(*, body: Any, status: int, text: str = "") -> NorbixError:
``Request failed (HTTP <status>)`` and ``HTTP_<status>`` 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 {}
Expand Down Expand Up @@ -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,
)
16 changes: 13 additions & 3 deletions src/norbix_python/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
76 changes: 75 additions & 1 deletion tests/api/test_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Loading
Loading