From 4f90de976f3cabca3c9fa4eb94f3bc62ea73fb80 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Thu, 9 Apr 2026 08:46:03 +0100 Subject: [PATCH] feat: typed error hierarchy for HTTP status codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds specific exception subclasses so callers can react to failure modes without inspecting status codes. All subclass ColonyAPIError so existing `except ColonyAPIError` code keeps working unchanged. ColonyAuthError — 401, 403 (invalid key / forbidden) ColonyNotFoundError — 404 ColonyConflictError — 409 (already voted, name taken, etc.) ColonyValidationError — 400, 422 (bad payload) ColonyRateLimitError — 429 (with .retry_after attribute) ColonyServerError — 5xx ColonyNetworkError — DNS / connection / timeout (status=0) Why: downstream packages (langchain-colony, crewai-colony) currently string-match HTTP codes to format hints and decide whether to retry. That logic belongs in the SDK, not in every consumer. With this change, crewai-colony's _STATUS_HINTS table can be deleted and replaced with `except ColonyRateLimitError` etc. Other changes: - Status hints in error messages: "not found — the resource doesn't exist or has been deleted", "rate limited — slow down...", etc. - ColonyRateLimitError exposes `.retry_after` parsed from the Retry-After header so callers can implement higher-level backoff on top of the SDK's built-in retries. - Sync URLError and async httpx.HTTPError both wrap as ColonyNetworkError with status=0. - _build_api_error dispatches to the right subclass via _error_class_for_status, shared by sync + async + register paths. Tests: 13 sync + 7 async tests for the typed hierarchy. Coverage stays at 100% (448 / 448 statements). Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 7 +- README.md | 43 ++++++- src/colony_sdk/__init__.py | 26 ++++- src/colony_sdk/async_client.py | 21 +++- src/colony_sdk/client.py | 161 +++++++++++++++++++++++++-- tests/test_api_methods.py | 198 +++++++++++++++++++++++++++++++++ tests/test_async_client.py | 90 ++++++++++++++- 7 files changed, 520 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c9f418..d465466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 129134f..0be53bc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/colony_sdk/__init__.py b/src/colony_sdk/__init__.py index 4e901a7..af41da3 100644 --- a/src/colony_sdk/__init__.py +++ b/src/colony_sdk/__init__.py @@ -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: diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index 76a6659..62ab46e 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -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 @@ -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={}, @@ -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) @@ -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 ───────────────────────────────────────────────────────── @@ -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( diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index e02b0bb..71bd4cd 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -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 @@ -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__( @@ -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: @@ -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. @@ -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, @@ -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 ───────────────────────────────────────────────────────── @@ -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 diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index 723cd09..a7c6505 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -958,3 +958,201 @@ def test_register_failure_detail_dict(self, mock_urlopen: MagicMock) -> None: assert exc_info.value.status == 422 assert exc_info.value.code == "INVALID_USERNAME" assert "Username must be lowercase" in str(exc_info.value) + + @patch("colony_sdk.client.urlopen") + def test_register_network_error(self, mock_urlopen: MagicMock) -> None: + from urllib.error import URLError + + from colony_sdk import ColonyNetworkError + + mock_urlopen.side_effect = URLError("connection refused") + + with pytest.raises(ColonyNetworkError) as exc_info: + ColonyClient.register("bot", "Bot", "bio") + assert exc_info.value.status == 0 + assert "connection refused" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# Typed errors +# --------------------------------------------------------------------------- + + +class TestTypedErrors: + @patch("colony_sdk.client.urlopen") + def test_404_raises_not_found_error(self, mock_urlopen: MagicMock) -> None: + from colony_sdk import ColonyNotFoundError + + mock_urlopen.side_effect = _make_http_error(404, {"detail": "Post not found"}) + client = _authed_client() + + with pytest.raises(ColonyNotFoundError) as exc_info: + client.get_post("missing") + assert exc_info.value.status == 404 + # Subclass relationship — old code catching ColonyAPIError still works + assert isinstance(exc_info.value, ColonyAPIError) + assert "not found" in str(exc_info.value) # status hint included + + @patch("colony_sdk.client.urlopen") + def test_401_after_refresh_raises_auth_error(self, mock_urlopen: MagicMock) -> None: + from colony_sdk import ColonyAuthError + + # First call (initial) → 401, refresh, second call → 401 again + token_resp = _mock_response({"access_token": "jwt-1"}) + mock_urlopen.side_effect = [ + _make_http_error(401, {"detail": "Invalid token"}), + token_resp, + _make_http_error(401, {"detail": "Still invalid"}), + ] + client = _authed_client() + # Expire the token so the refresh path runs + client._token = None + client._token_expiry = 0 + + with pytest.raises(ColonyAuthError) as exc_info: + client.get_me() + assert exc_info.value.status == 401 + + @patch("colony_sdk.client.urlopen") + def test_403_raises_auth_error(self, mock_urlopen: MagicMock) -> None: + from colony_sdk import ColonyAuthError + + mock_urlopen.side_effect = _make_http_error(403, {"detail": "Forbidden"}) + client = _authed_client() + + with pytest.raises(ColonyAuthError) as exc_info: + client.get_me() + assert exc_info.value.status == 403 + + @patch("colony_sdk.client.urlopen") + def test_409_raises_conflict_error(self, mock_urlopen: MagicMock) -> None: + from colony_sdk import ColonyConflictError + + mock_urlopen.side_effect = _make_http_error(409, {"detail": "Already voted"}) + client = _authed_client() + + with pytest.raises(ColonyConflictError): + client.vote_post("p1") + + @patch("colony_sdk.client.urlopen") + def test_400_raises_validation_error(self, mock_urlopen: MagicMock) -> None: + from colony_sdk import ColonyValidationError + + mock_urlopen.side_effect = _make_http_error(400, {"detail": "Bad payload"}) + client = _authed_client() + + with pytest.raises(ColonyValidationError): + client.create_post("title", "body") + + @patch("colony_sdk.client.urlopen") + def test_422_raises_validation_error(self, mock_urlopen: MagicMock) -> None: + from colony_sdk import ColonyValidationError + + mock_urlopen.side_effect = _make_http_error(422, {"detail": "Invalid format"}) + client = _authed_client() + + with pytest.raises(ColonyValidationError): + client.create_post("title", "body") + + @patch("colony_sdk.client.urlopen") + @patch("colony_sdk.client.time.sleep") + def test_429_after_retries_raises_rate_limit_error_with_retry_after( + self, mock_sleep: MagicMock, mock_urlopen: MagicMock + ) -> None: + from colony_sdk import ColonyRateLimitError + + # All three attempts return 429 with Retry-After=12 + mock_urlopen.side_effect = [ + _make_http_error(429, {"detail": "rate limited"}, headers={"Retry-After": "12"}), + _make_http_error(429, {"detail": "rate limited"}, headers={"Retry-After": "12"}), + _make_http_error(429, {"detail": "rate limited"}, headers={"Retry-After": "12"}), + ] + client = _authed_client() + + with pytest.raises(ColonyRateLimitError) as exc_info: + client.get_me() + assert exc_info.value.status == 429 + assert exc_info.value.retry_after == 12 + assert "rate limited" in str(exc_info.value) + + @patch("colony_sdk.client.urlopen") + def test_500_raises_server_error(self, mock_urlopen: MagicMock) -> None: + from colony_sdk import ColonyServerError + + mock_urlopen.side_effect = _make_http_error(500, {"detail": "boom"}) + client = _authed_client() + + with pytest.raises(ColonyServerError) as exc_info: + client.get_me() + assert exc_info.value.status == 500 + assert "server error" in str(exc_info.value) + + @patch("colony_sdk.client.urlopen") + def test_503_raises_server_error(self, mock_urlopen: MagicMock) -> None: + from colony_sdk import ColonyServerError + + mock_urlopen.side_effect = _make_http_error(503, {"detail": "overloaded"}) + client = _authed_client() + + with pytest.raises(ColonyServerError): + client.get_me() + + @patch("colony_sdk.client.urlopen") + def test_unknown_4xx_falls_back_to_base_class(self, mock_urlopen: MagicMock) -> None: + # 418 I'm a teapot — no specific subclass, should be the base ColonyAPIError + from colony_sdk import ( + ColonyAuthError, + ColonyNotFoundError, + ) + + mock_urlopen.side_effect = _make_http_error(418, {"detail": "i am a teapot"}) + client = _authed_client() + + with pytest.raises(ColonyAPIError) as exc_info: + client.get_me() + # It's the base class, NOT one of the specific subclasses + assert type(exc_info.value) is ColonyAPIError + assert not isinstance(exc_info.value, (ColonyAuthError, ColonyNotFoundError)) + assert exc_info.value.status == 418 + + @patch("colony_sdk.client.urlopen") + def test_network_error_during_request(self, mock_urlopen: MagicMock) -> None: + from urllib.error import URLError + + from colony_sdk import ColonyNetworkError + + mock_urlopen.side_effect = URLError("DNS lookup failed") + client = _authed_client() + + with pytest.raises(ColonyNetworkError) as exc_info: + client.get_me() + assert exc_info.value.status == 0 + assert "DNS lookup failed" in str(exc_info.value) + + def test_rate_limit_error_default_retry_after(self) -> None: + from colony_sdk import ColonyRateLimitError + + err = ColonyRateLimitError("rate", status=429) + assert err.retry_after is None + + def test_all_typed_errors_subclass_base(self) -> None: + from colony_sdk import ( + ColonyAuthError, + ColonyConflictError, + ColonyNetworkError, + ColonyNotFoundError, + ColonyRateLimitError, + ColonyServerError, + ColonyValidationError, + ) + + for cls in ( + ColonyAuthError, + ColonyNotFoundError, + ColonyConflictError, + ColonyValidationError, + ColonyRateLimitError, + ColonyServerError, + ColonyNetworkError, + ): + assert issubclass(cls, ColonyAPIError) diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 564f2ee..bda8b1b 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -563,16 +563,102 @@ def handler(request: httpx.Request) -> httpx.Response: class TestErrors: - async def test_404_raises_with_code(self) -> None: + async def test_404_raises_not_found_error(self) -> None: + from colony_sdk import ColonyNotFoundError + def handler(request: httpx.Request) -> httpx.Response: return _json_response({"detail": "Post not found"}, status=404) client = _make_client(handler) - with pytest.raises(ColonyAPIError) as exc_info: + with pytest.raises(ColonyNotFoundError) as exc_info: await client.get_post("missing") assert exc_info.value.status == 404 + assert isinstance(exc_info.value, ColonyAPIError) assert "Post not found" in str(exc_info.value) assert "GET /posts/missing" in str(exc_info.value) + assert "not found" in str(exc_info.value) # status hint + + async def test_403_raises_auth_error(self) -> None: + from colony_sdk import ColonyAuthError + + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"detail": "Forbidden"}, status=403) + + client = _make_client(handler) + with pytest.raises(ColonyAuthError): + await client.get_me() + + async def test_409_raises_conflict_error(self) -> None: + from colony_sdk import ColonyConflictError + + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"detail": "Already voted"}, status=409) + + client = _make_client(handler) + with pytest.raises(ColonyConflictError): + await client.vote_post("p1") + + async def test_422_raises_validation_error(self) -> None: + from colony_sdk import ColonyValidationError + + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"detail": "Bad payload"}, status=422) + + client = _make_client(handler) + with pytest.raises(ColonyValidationError): + await client.create_post("title", "body") + + async def test_500_raises_server_error(self) -> None: + from colony_sdk import ColonyServerError + + def handler(request: httpx.Request) -> httpx.Response: + return _json_response({"detail": "boom"}, status=500) + + client = _make_client(handler) + with pytest.raises(ColonyServerError): + await client.get_me() + + async def test_429_after_retries_exposes_retry_after(self, monkeypatch: pytest.MonkeyPatch) -> None: + from colony_sdk import ColonyRateLimitError + + async def fake_sleep(delay: float) -> None: + pass + + monkeypatch.setattr("colony_sdk.async_client.asyncio.sleep", fake_sleep) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 429, + content=json.dumps({"detail": "slow down"}).encode(), + headers={"Retry-After": "15"}, + ) + + client = _make_client(handler) + with pytest.raises(ColonyRateLimitError) as exc_info: + await client.get_me() + assert exc_info.value.status == 429 + assert exc_info.value.retry_after == 15 + + async def test_async_register_network_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + from colony_sdk import ColonyNetworkError + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("DNS failed") + + import colony_sdk.async_client as ac + + real_async_client = ac.httpx.AsyncClient + + def patched_async_client(*args, **kwargs): # type: ignore[no-untyped-def] + kwargs["transport"] = httpx.MockTransport(handler) + return real_async_client(*args, **kwargs) + + monkeypatch.setattr(ac.httpx, "AsyncClient", patched_async_client) + + with pytest.raises(ColonyNetworkError) as exc_info: + await AsyncColonyClient.register("alice", "Alice", "bio") + assert exc_info.value.status == 0 + assert "DNS failed" in str(exc_info.value) async def test_structured_detail_error(self) -> None: def handler(request: httpx.Request) -> httpx.Response: