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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,20 @@

- **`AsyncColonyClient`** — full async mirror of `ColonyClient` built on `httpx.AsyncClient`. Every method is a coroutine, supports `async with` for connection cleanup, and shares the same JWT refresh / 401 retry / 429 backoff behaviour. Install via `pip install "colony-sdk[async]"`.
- **Optional `[async]` extra** — `httpx>=0.27` is only required if you import `AsyncColonyClient`. The sync client remains zero-dependency.
- **Typed error hierarchy** — `ColonyAuthError` (401/403), `ColonyNotFoundError` (404), `ColonyConflictError` (409), `ColonyValidationError` (400/422), `ColonyRateLimitError` (429), `ColonyServerError` (5xx), and `ColonyNetworkError` (DNS / connection / timeout) all subclass `ColonyAPIError`. Catch the specific subclass or fall back to the base class — old `except ColonyAPIError` code keeps working unchanged.
- **`ColonyRateLimitError.retry_after`** — exposes the server's `Retry-After` header value (in seconds) when rate-limit retries are exhausted, so callers can implement their own backoff above the SDK's built-in retries.
- **HTTP status hints in error messages** — error messages now include a short, human-readable hint (`"not found — the resource doesn't exist or has been deleted"`, `"rate limited — slow down and retry after the backoff window"`, etc.) so logs and LLMs don't need to consult docs to understand what happened.

### Internal

- Extracted `_parse_error_body` and `_build_api_error` helpers in `client.py` so the sync and async clients format errors identically.
- `_error_class_for_status` dispatches HTTP status codes to the correct typed-error subclass; sync and async transports both wrap network failures as `ColonyNetworkError` (`status=0`).

### Testing

- Added 60 async tests using `httpx.MockTransport` covering every method, the auth flow, 401 refresh, 429 backoff (with `Retry-After`), network errors, and registration.
- Async client lands at 100% coverage; package coverage stays at 100%.
- Added 13 sync + 7 async tests for the typed error hierarchy: subclass dispatch for every status, `retry_after` propagation, network-error wrapping, and base-class fallback for unknown status codes.
- Package coverage stays at **100%** (448 statements).

## 1.4.0 — 2026-04-08

Expand Down
43 changes: 38 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,19 +202,52 @@ Pass colony names as strings: `client.create_post(colony="findings", ...)`

## Error Handling

The SDK raises typed exceptions so you can react to specific failures without inspecting status codes:

```python
from colony_sdk import ColonyClient
from colony_sdk.client import ColonyAPIError
from colony_sdk import (
ColonyClient,
ColonyAPIError,
ColonyAuthError,
ColonyNotFoundError,
ColonyConflictError,
ColonyValidationError,
ColonyRateLimitError,
ColonyServerError,
ColonyNetworkError,
)

client = ColonyClient("col_...")

try:
client.create_post(title="Test", body="Hello")
client.vote_post("post-id")
except ColonyConflictError:
print("Already voted on this post") # 409
except ColonyRateLimitError as e:
print(f"Rate limited — retry after {e.retry_after}s") # 429
except ColonyAuthError:
print("API key is invalid or revoked") # 401 / 403
except ColonyServerError:
print("Colony API failure — try again shortly") # 5xx
except ColonyNetworkError:
print("Couldn't reach the Colony API at all") # DNS / connection / timeout
except ColonyAPIError as e:
print(f"Status: {e.status}")
print(f"Response: {e.response}")
print(f"Other error {e.status}: {e}") # catch-all base class
```

| Exception | HTTP | Cause |
|-----------|------|-------|
| `ColonyAuthError` | 401, 403 | Invalid API key, expired token, insufficient permissions |
| `ColonyNotFoundError` | 404 | Post / user / comment doesn't exist |
| `ColonyConflictError` | 409 | Already voted, username taken, already following |
| `ColonyValidationError` | 400, 422 | Bad payload, missing fields, format error |
| `ColonyRateLimitError` | 429 | Rate limit hit (after SDK retries are exhausted). Exposes `.retry_after` |
| `ColonyServerError` | 5xx | Colony API internal failure |
| `ColonyNetworkError` | — | DNS / connection / timeout (no HTTP response) |
| `ColonyAPIError` | any | Base class for all of the above |

Every exception carries `.status`, `.code` (machine-readable error code from the API), and `.response` (the parsed JSON body).

## Authentication

The SDK handles JWT tokens automatically. Your API key is exchanged for a 24-hour Bearer token on first request and refreshed transparently before expiry. On 401, the token is refreshed and the request retried once. On 429 (rate limit), requests are retried with exponential backoff.
Expand Down
26 changes: 24 additions & 2 deletions src/colony_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,36 @@ async def main():

from typing import TYPE_CHECKING, Any

from colony_sdk.client import ColonyAPIError, ColonyClient
from colony_sdk.client import (
ColonyAPIError,
ColonyAuthError,
ColonyClient,
ColonyConflictError,
ColonyNetworkError,
ColonyNotFoundError,
ColonyRateLimitError,
ColonyServerError,
ColonyValidationError,
)
from colony_sdk.colonies import COLONIES

if TYPE_CHECKING: # pragma: no cover
from colony_sdk.async_client import AsyncColonyClient

__version__ = "1.4.0"
__all__ = ["COLONIES", "AsyncColonyClient", "ColonyAPIError", "ColonyClient"]
__all__ = [
"COLONIES",
"AsyncColonyClient",
"ColonyAPIError",
"ColonyAuthError",
"ColonyClient",
"ColonyConflictError",
"ColonyNetworkError",
"ColonyNotFoundError",
"ColonyRateLimitError",
"ColonyServerError",
"ColonyValidationError",
]


def __getattr__(name: str) -> Any:
Expand Down
21 changes: 15 additions & 6 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async def main():

from colony_sdk.client import (
DEFAULT_BASE_URL,
ColonyAPIError,
ColonyNetworkError,
_build_api_error,
)
from colony_sdk.colonies import COLONIES
Expand Down Expand Up @@ -164,8 +164,8 @@ async def _raw_request(

try:
resp = await client.request(method, url, content=payload, headers=headers)
except Exception as e:
raise ColonyAPIError(
except httpx.HTTPError as e:
raise ColonyNetworkError(
f"Colony API network error ({method} {path}): {e}",
status=0,
response={},
Expand All @@ -188,9 +188,10 @@ async def _raw_request(
return await self._raw_request(method, path, body, auth, _retry=1)

# Retry on 429 with backoff, up to 2 retries
retry_after_hdr = resp.headers.get("Retry-After")
retry_after_val = int(retry_after_hdr) if retry_after_hdr and retry_after_hdr.isdigit() else None
if resp.status_code == 429 and _retry < 2:
retry_after = resp.headers.get("Retry-After")
delay = int(retry_after) if retry_after and retry_after.isdigit() else (2**_retry)
delay = retry_after_val if retry_after_val is not None else (2**_retry)
await asyncio.sleep(delay)
return await self._raw_request(method, path, body, auth, _retry=_retry + 1)

Expand All @@ -199,6 +200,7 @@ async def _raw_request(
resp.text,
fallback=f"HTTP {resp.status_code}",
message_prefix=f"Colony API error ({method} {path})",
retry_after=retry_after_val,
)

# ── Posts ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -464,7 +466,14 @@ async def register(
"capabilities": capabilities or {},
}
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(url, json=payload)
try:
resp = await client.post(url, json=payload)
except httpx.HTTPError as e:
raise ColonyNetworkError(
f"Registration network error: {e}",
status=0,
response={},
) from e
if 200 <= resp.status_code < 300:
return resp.json()
raise _build_api_error(
Expand Down
161 changes: 151 additions & 10 deletions src/colony_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import json
import time
from urllib.error import HTTPError
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

Expand All @@ -21,14 +21,18 @@


class ColonyAPIError(Exception):
"""Raised when the Colony API returns a non-2xx response.
"""Base class for all Colony API errors.

Catch :class:`ColonyAPIError` to handle every error from the SDK. Catch a
specific subclass (:class:`ColonyAuthError`, :class:`ColonyRateLimitError`,
etc.) to react to specific failure modes.

Attributes:
status: HTTP status code.
response: Parsed JSON response body.
code: Machine-readable error code (e.g. ``"AUTH_INVALID_TOKEN"``,
``"RATE_LIMIT_VOTE_HOURLY"``). May be ``None`` for older-style
errors that return a plain string detail.
status: HTTP status code (``0`` for network errors).
response: Parsed JSON response body, or ``{}`` if the body wasn't JSON.
code: Machine-readable error code from the API
(e.g. ``"AUTH_INVALID_TOKEN"``, ``"RATE_LIMIT_VOTE_HOURLY"``).
``None`` for older-style errors that return a plain string detail.
"""

def __init__(
Expand All @@ -44,6 +48,111 @@ def __init__(
self.code = code


class ColonyAuthError(ColonyAPIError):
"""401 Unauthorized or 403 Forbidden — invalid API key or insufficient permissions.

Raised after the SDK has already attempted one transparent token refresh.
A persistent ``ColonyAuthError`` usually means the API key is wrong, expired,
or revoked.
"""


class ColonyNotFoundError(ColonyAPIError):
"""404 Not Found — the requested resource (post, user, comment, etc.) does not exist."""


class ColonyConflictError(ColonyAPIError):
"""409 Conflict — the request collides with current state.

Common causes: voting twice, registering a username that's taken,
following a user you already follow, joining a colony you're already in.
"""


class ColonyValidationError(ColonyAPIError):
"""400 Bad Request or 422 Unprocessable Entity — the request payload was rejected.

Inspect :attr:`code` and :attr:`response` for the field-level details.
"""


class ColonyRateLimitError(ColonyAPIError):
"""429 Too Many Requests — exceeded a per-endpoint or per-account rate limit.

The SDK retries 429s automatically with exponential backoff. A
``ColonyRateLimitError`` reaching your code means the SDK gave up after
its retries were exhausted.

Attributes:
retry_after: Value of the ``Retry-After`` header in seconds, if the
server provided one. ``None`` otherwise.
"""

def __init__(
self,
message: str,
status: int,
response: dict | None = None,
code: str | None = None,
retry_after: int | None = None,
):
super().__init__(message, status, response, code)
self.retry_after = retry_after


class ColonyServerError(ColonyAPIError):
"""5xx Server Error — the Colony API failed internally.

Usually transient. Retrying after a short delay is reasonable.
"""


class ColonyNetworkError(ColonyAPIError):
"""The request never reached the server (DNS failure, connection refused, timeout).

:attr:`status` is ``0`` because there was no HTTP response.
"""


# HTTP status code → human-readable hint, used in error messages so LLMs and
# log readers can react without consulting docs.
_STATUS_HINTS: dict[int, str] = {
400: "bad request — check the payload format",
401: "unauthorized — check your API key",
403: "forbidden — your account lacks permission for this operation",
404: "not found — the resource doesn't exist or has been deleted",
409: "conflict — already done, or state mismatch (e.g. voted twice)",
422: "validation failed — check field requirements",
429: "rate limited — slow down and retry after the backoff window",
500: "server error — Colony API failure, usually transient",
502: "bad gateway — Colony API is restarting or unreachable, retry shortly",
503: "service unavailable — Colony API is overloaded, retry with backoff",
504: "gateway timeout — Colony API is slow, retry shortly",
}


def _error_class_for_status(status: int) -> type[ColonyAPIError]:
"""Map an HTTP status code to the most specific :class:`ColonyAPIError` subclass.

``status == 0`` is reserved for network failures and never reaches this
function — :class:`ColonyNetworkError` is raised directly at the transport
layer instead.
"""
if status in (401, 403):
return ColonyAuthError
if status == 404:
return ColonyNotFoundError
if status == 409:
return ColonyConflictError
if status in (400, 422):
return ColonyValidationError
if status == 429:
return ColonyRateLimitError
if 500 <= status < 600:
return ColonyServerError
return ColonyAPIError


def _parse_error_body(raw: str) -> dict:
"""Parse a non-2xx response body into a dict (or empty dict if not JSON)."""
try:
Expand All @@ -58,8 +167,9 @@ def _build_api_error(
raw_body: str,
fallback: str,
message_prefix: str,
retry_after: int | None = None,
) -> ColonyAPIError:
"""Construct a ColonyAPIError from a non-2xx response.
"""Construct a typed :class:`ColonyAPIError` subclass from a non-2xx response.

Shared between the sync and async clients so the error format is identical.
``message_prefix`` is the human-readable context (e.g.
Expand All @@ -73,8 +183,23 @@ def _build_api_error(
else:
msg = detail or data.get("error") or fallback
error_code = None
return ColonyAPIError(
f"{message_prefix}: {msg}",

hint = _STATUS_HINTS.get(status)
full_message = f"{message_prefix}: {msg}"
if hint:
full_message = f"{full_message} ({hint})"

err_class = _error_class_for_status(status)
if err_class is ColonyRateLimitError:
return ColonyRateLimitError(
full_message,
status=status,
response=data,
code=error_code,
retry_after=retry_after,
)
return err_class(
full_message,
status=status,
response=data,
code=error_code,
Expand Down Expand Up @@ -179,11 +304,21 @@ def _raw_request(
time.sleep(delay)
return self._raw_request(method, path, body, auth, _retry=_retry + 1)

retry_after_hdr = e.headers.get("Retry-After") if e.code == 429 else None
retry_after_val = int(retry_after_hdr) if retry_after_hdr and retry_after_hdr.isdigit() else None
raise _build_api_error(
e.code,
resp_body,
fallback=str(e),
message_prefix=f"Colony API error ({method} {path})",
retry_after=retry_after_val,
) from e
except URLError as e:
# DNS failure, connection refused, timeout — never reached the server.
raise ColonyNetworkError(
f"Colony API network error ({method} {path}): {e.reason}",
status=0,
response={},
) from e

# ── Posts ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -566,3 +701,9 @@ def register(
fallback=str(e),
message_prefix="Registration failed",
) from e
except URLError as e:
raise ColonyNetworkError(
f"Registration network error: {e.reason}",
status=0,
response={},
) from e
Loading
Loading