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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ All notable changes to this project are documented here. The format follows
application-lifetime client reuse, HTTP connection pooling, and shutdown.
- Returned group memberships as contract-validated `PrincipalMember` values
and added cursor-aware membership page and collection helpers.
- Parsed both standard `Retry-After` forms into safe, non-negative rate-limit
delays and ignored malformed or non-finite values.

### Security

Expand Down
4 changes: 4 additions & 0 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ and request ID where available. Bearer tokens, secret-bearing request headers
and bodies, and values under sensitive query parameter names are redacted from
their diagnostics.

`RateLimitError.retry_after` exposes a valid `Retry-After` delay as
non-negative seconds. Both integer delay values and HTTP dates are supported;
the attribute is `None` when the header is absent or malformed.

## Complete OpenAPI operation surface

The Hubuum v0.0.3 OpenAPI contract contains 196 operations. Every operation is
Expand Down
121 changes: 117 additions & 4 deletions src/hubuum_client/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
from __future__ import annotations

import json
import re
from collections.abc import Mapping
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from typing import Any, TypeVar
from urllib.parse import quote_plus, unquote, urlsplit

Expand Down Expand Up @@ -40,6 +42,47 @@
_ASCII_CONTROL_END = 32
_ASCII_DELETE = 127
_MAX_PORT = 65_535
_LEAP_SECOND = 60
_MONTHS = {
"Jan": 1,
"Feb": 2,
"Mar": 3,
"Apr": 4,
"May": 5,
"Jun": 6,
"Jul": 7,
"Aug": 8,
"Sep": 9,
"Oct": 10,
"Nov": 11,
"Dec": 12,
}
_SHORT_WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
_LONG_WEEKDAYS = (
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
)
_SHORT_WEEKDAY_PATTERN = r"(?P<weekday>Mon|Tue|Wed|Thu|Fri|Sat|Sun)"
_LONG_WEEKDAY_PATTERN = r"(?P<weekday>Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)"
_MONTH_PATTERN = r"(?P<month>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"
_TIME_PATTERN = r"(?P<hour>[0-9]{2}):(?P<minute>[0-9]{2}):(?P<second>[0-9]{2})"
_IMF_FIXDATE = re.compile(
rf"{_SHORT_WEEKDAY_PATTERN}, (?P<day>[0-9]{{2}}) {_MONTH_PATTERN} "
rf"(?P<year>[0-9]{{4}}) {_TIME_PATTERN} GMT"
)
_RFC850_DATE = re.compile(
rf"{_LONG_WEEKDAY_PATTERN}, (?P<day>[0-9]{{2}})-{_MONTH_PATTERN}-"
rf"(?P<year>[0-9]{{2}}) {_TIME_PATTERN} GMT"
)
_ASCTIME_DATE = re.compile(
rf"{_SHORT_WEEKDAY_PATTERN} {_MONTH_PATTERN} (?P<day>[0-9]{{2}}| [0-9]) "
rf"{_TIME_PATTERN} (?P<year>[0-9]{{4}})"
)


def _reject_ambiguous_characters(value: str, *, label: str) -> None:
Expand Down Expand Up @@ -203,6 +246,76 @@ def safe_response_url(response: httpx.Response) -> str:
return str(response.request.url.copy_with(query=None, fragment=None))


def _parse_http_date(value: str, *, received_at: datetime) -> datetime | None:
received_at = (
received_at.replace(tzinfo=UTC)
if received_at.tzinfo is None
else received_at.astimezone(UTC)
)
match = _IMF_FIXDATE.fullmatch(value)
weekdays = _SHORT_WEEKDAYS
rfc850 = False
if match is None:
match = _RFC850_DATE.fullmatch(value)
weekdays = _LONG_WEEKDAYS
rfc850 = match is not None
if match is None:
match = _ASCTIME_DATE.fullmatch(value)
weekdays = _SHORT_WEEKDAYS
if match is None:
return None

try:
month = _MONTHS[match.group("month")]
day = int(match.group("day").strip())
hour = int(match.group("hour"))
minute = int(match.group("minute"))
second = int(match.group("second"))
if second > _LEAP_SECOND:
return None
year = int(match.group("year"))
if rfc850:
year += received_at.year // 100 * 100
if year > received_at.year + 50 or (
year == received_at.year + 50
and (month, day, hour, minute, second)
> (
received_at.month,
received_at.day,
received_at.hour,
received_at.minute,
received_at.second,
)
):
year -= 100
parsed = datetime(year, month, day, hour, minute, min(second, 59), tzinfo=UTC)
if match.group("weekday") != weekdays[parsed.weekday()]:
return None
return parsed + timedelta(seconds=second == _LEAP_SECOND)
except (KeyError, ValueError, OverflowError):
return None


def _parse_retry_after(value: str | None, *, received_at: datetime) -> float | None:
if value is None:
return None
value = value.strip(" \t")
if value.isascii() and value.isdigit():
try:
return float(int(value))
except OverflowError:
return None
retry_at = _parse_http_date(value, received_at=received_at)
if retry_at is None:
return None
if received_at.tzinfo is None:
received_at = received_at.replace(tzinfo=UTC)
try:
return max(0.0, (retry_at - received_at.astimezone(UTC)).total_seconds())
except (ValueError, OverflowError):
return None


def raise_api_error(response: httpx.Response) -> None:
"""Map a failed HTTP response onto the public exception hierarchy."""
if response.is_success:
Expand Down Expand Up @@ -240,10 +353,10 @@ def raise_api_error(response: httpx.Response) -> None:
request_id=redact_text(response.headers.get("x-request-id", ""), secrets) or None,
)
if isinstance(api_error, RateLimitError):
try:
api_error.retry_after = float(response.headers["retry-after"])
except (KeyError, ValueError):
api_error.retry_after = None
api_error.retry_after = _parse_retry_after(
response.headers.get("retry-after"),
received_at=datetime.now(UTC),
)
raise api_error


Expand Down
17 changes: 17 additions & 0 deletions tests/unit/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
Credentials,
DecodeError,
Query,
RateLimitError,
RequestOptions,
TransportError,
)
Expand Down Expand Up @@ -133,6 +134,22 @@ def handler(request: httpx.Request) -> httpx.Response:
assert not client.is_authenticated


async def test_async_rate_limit_error_parses_retry_after_http_date() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
429,
json={"error": "RateLimited", "message": "slow down"},
headers={"Retry-After": "Thu, 23 Jul 2099 12:02:00 GMT"},
)

async with _client(handler) as client:
with pytest.raises(RateLimitError) as raised:
await client.request("GET", "/api/v1/classes")

assert raised.value.retry_after is not None
assert raised.value.retry_after > 0


async def test_async_headers_and_exceptions_are_secret_safe() -> None:
def authenticated(request: httpx.Request) -> httpx.Response:
assert request.headers.get_list("authorization") == ["Bearer async-token"]
Expand Down
73 changes: 73 additions & 0 deletions tests/unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from urllib.parse import quote_plus

Expand Down Expand Up @@ -33,6 +34,7 @@
ResultCardinalityError,
TransportError,
)
from hubuum_client._transport import _parse_retry_after


def _client(
Expand Down Expand Up @@ -255,6 +257,77 @@ def handler(request: httpx.Request) -> httpx.Response:
assert error.retry_after == 2


@pytest.mark.parametrize(
"value",
["-1", "1.5", "nan", "inf", "not-a-date", "9" * 400],
)
def test_invalid_retry_after_values_are_ignored(value: str) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
429,
json={"error": "RateLimited", "message": "slow down"},
headers={"Retry-After": value},
)

with _client(handler) as client, pytest.raises(RateLimitError) as raised:
client.request("GET", "/api/v1/classes")

assert raised.value.retry_after is None


def test_retry_after_http_dates_become_non_negative_delays() -> None:
received_at = datetime(2026, 7, 23, 12, tzinfo=UTC)

for value in (
"Thu, 23 Jul 2026 12:02:00 GMT",
"Thursday, 23-Jul-26 12:02:00 GMT",
"Thu Jul 23 12:02:00 2026",
):
assert _parse_retry_after(value, received_at=received_at) == 120
assert (
_parse_retry_after(
"Thu, 23 Jul 2026 11:58:00 GMT",
received_at=received_at,
)
== 0
)
assert (
_parse_retry_after(
"Thu, 31 Dec 2026 23:59:60 GMT",
received_at=datetime(2026, 12, 31, 23, 59, 59, tzinfo=UTC),
)
== 1
)
assert (
_parse_retry_after(
"Saturday, 23-Jul-77 12:02:00 GMT",
received_at=received_at,
)
== 0
)


@pytest.mark.parametrize(
"value",
[
"Thu, 23 Jul 2026 12:02:00 UTC",
"Thu, 23 Jul 2026 12:02:00 +0000",
"Thu, 23 Jul 2026 12:02:00 GMT trailing",
"thu, 23 Jul 2026 12:02:00 GMT",
"Fri, 23 Jul 2026 12:02:00 GMT",
"Thu, 23 Jul 2026 12:02:00 GMT",
],
)
def test_non_http_retry_after_dates_are_ignored(value: str) -> None:
assert (
_parse_retry_after(
value,
received_at=datetime(2026, 7, 23, 12, tzinfo=UTC),
)
is None
)


def test_error_responses_and_transport_failures_redact_request_secrets() -> None:
password = "login-password-secret"

Expand Down