From a732df91eeda26ed9b0d2170f034a5cbe428ffd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grok=C2=B7Orion?= Date: Sun, 11 Oct 2026 03:10:34 +0800 Subject: [PATCH] feat(client): raise typed TokenLabError with status, code, request id and Retry-After --- README.md | 18 +++++++ src/tokenlab/__init__.py | 3 +- src/tokenlab/client.py | 74 +++++++++++++++++++++++++- tests/test_errors.py | 109 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 tests/test_errors.py diff --git a/README.md b/README.md index 44de8de..06859ee 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,24 @@ with TokenLabClient(timeout=60.0) as tokenlab: The client reads `TOKENLAB_API_KEY` from the environment. This synchronous `/v1/systemone` call preserves typed answers, probabilities, optional confidence, and usage. It is not Chat, streaming, or Batch. A decision does not authorize a refund or another side effect. Errors are returned without automatic resubmission. Available in SDK 0.1.2+. +## Handling errors + +Non-success responses raise `tokenlab.TokenLabError`, a `RuntimeError` subclass whose message still starts with `TokenLab request failed:`. It exposes `status_code`, `code`, `type`, `message` (from the JSON `{"error": {...}}` body, or the raw body when it is not JSON), `request_id` (the `x-request-id` header), and `retry_after` (the `Retry-After` header in seconds, or `None`). Network errors such as timeouts are raised by `httpx` unchanged. + +```python +from tokenlab import TokenLabClient, TokenLabError + +with TokenLabClient() as tokenlab: + try: + tokenlab.create_response({"model": "gpt-5.5", "input": "Hello"}) + except TokenLabError as err: + print(err.status_code, err.code, err.message, err.request_id) + if err.status_code == 429 and err.retry_after is not None: + print(f"Rate limited; you may retry after {err.retry_after} seconds") +``` + +The SDK never retries automatically; whether and when to resubmit is up to you. Added after SDK 0.1.4 (not yet released). + ## Base URLs - API: `https://api.tokenlab.sh` diff --git a/src/tokenlab/__init__.py b/src/tokenlab/__init__.py index 26c1218..257336f 100644 --- a/src/tokenlab/__init__.py +++ b/src/tokenlab/__init__.py @@ -1,7 +1,8 @@ -from .client import TOKENLAB_API_BASE, TOKENLAB_OPENAI_BASE_URL, TokenLabClient +from .client import TOKENLAB_API_BASE, TOKENLAB_OPENAI_BASE_URL, TokenLabClient, TokenLabError __all__ = [ "TOKENLAB_API_BASE", "TOKENLAB_OPENAI_BASE_URL", "TokenLabClient", + "TokenLabError", ] diff --git a/src/tokenlab/client.py b/src/tokenlab/client.py index a16158e..fa3b34e 100644 --- a/src/tokenlab/client.py +++ b/src/tokenlab/client.py @@ -9,6 +9,78 @@ TOKENLAB_OPENAI_BASE_URL = f"{TOKENLAB_API_BASE}/v1" +class TokenLabError(RuntimeError): + """Raised when TokenLab returns a non-success HTTP status. + + Subclasses ``RuntimeError`` for backward compatibility, and ``str(error)`` + still starts with ``"TokenLab request failed:"`` followed by the raw body. + The SDK never retries automatically; use ``retry_after`` to decide yourself. + """ + + def __init__( + self, + *, + status_code: int, + message: str, + code: str | None = None, + type: str | None = None, + request_id: str | None = None, + retry_after: float | None = None, + body: str = "", + ) -> None: + super().__init__(f"TokenLab request failed: {body}") + self.status_code = status_code + self.code = code + self.type = type + self.message = message + self.request_id = request_id + self.retry_after = retry_after + self.body = body + + @classmethod + def from_response(cls, response: httpx.Response) -> "TokenLabError": + body = response.text + message = body + code: str | None = None + error_type: str | None = None + try: + payload = response.json() + except ValueError: + payload = None + error = payload.get("error") if isinstance(payload, dict) else None + if isinstance(error, dict): + if error.get("message") is not None: + message = str(error["message"]) + if error.get("code") is not None: + code = str(error["code"]) + if error.get("type") is not None: + error_type = str(error["type"]) + elif isinstance(error, str): + message = error + return cls( + status_code=response.status_code, + message=message, + code=code, + type=error_type, + request_id=response.headers.get("x-request-id"), + retry_after=_parse_retry_after(response.headers.get("retry-after")), + body=body, + ) + + +def _parse_retry_after(value: str | None) -> float | None: + """Parse a Retry-After header given in seconds; anything else yields None.""" + if value is None: + return None + try: + seconds = float(value.strip()) + except ValueError: + return None + if seconds != seconds or seconds < 0 or seconds == float("inf"): + return None + return seconds + + class TokenLabClient: """Small synchronous TokenLab client for discovery and native endpoints.""" @@ -62,7 +134,7 @@ def request( try: response.raise_for_status() except httpx.HTTPStatusError as exc: - raise RuntimeError(f"TokenLab request failed: {response.text}") from exc + raise TokenLabError.from_response(response) from exc return response.json() if response.content else None def list_models(self, **query: str) -> Any: diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..2085792 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,109 @@ +import unittest + +import httpx + +import tokenlab +from tokenlab import TokenLabClient, TokenLabError + + +def make_client(handler): + client = TokenLabClient(api_key="sk-test") + client._client.close() + client._client = httpx.Client(transport=httpx.MockTransport(handler)) + return client + + +class TokenLabErrorTest(unittest.TestCase): + def test_exported_and_runtime_error_subclass(self): + self.assertIs(tokenlab.TokenLabError, TokenLabError) + self.assertIn("TokenLabError", tokenlab.__all__) + self.assertTrue(issubclass(TokenLabError, RuntimeError)) + + def test_json_error_body_with_retry_after(self): + calls = [] + body = {"error": {"code": "rate_limited", "type": "rate_limit_error", "message": "Too many requests"}} + + def handle(request): + calls.append(request) + return httpx.Response(429, json=body, headers={"x-request-id": "req_123", "Retry-After": "7"}) + + with make_client(handle) as client: + with self.assertRaises(TokenLabError) as ctx: + client.list_models() + err = ctx.exception + self.assertIsInstance(err, RuntimeError) + self.assertEqual(err.status_code, 429) + self.assertEqual(err.code, "rate_limited") + self.assertEqual(err.type, "rate_limit_error") + self.assertEqual(err.message, "Too many requests") + self.assertEqual(err.request_id, "req_123") + self.assertEqual(err.retry_after, 7.0) + self.assertTrue(str(err).startswith("TokenLab request failed:")) + self.assertIn("rate_limited", str(err)) + self.assertIsInstance(err.__cause__, httpx.HTTPStatusError) + self.assertEqual(len(calls), 1) # no automatic retry + + def test_json_error_body_without_retry_after(self): + def handle(request): + return httpx.Response(400, json={"error": {"code": "invalid_request", "message": "model is required"}}) + + with make_client(handle) as client: + with self.assertRaises(TokenLabError) as ctx: + client.create_response({}) + err = ctx.exception + self.assertEqual(err.status_code, 400) + self.assertEqual(err.code, "invalid_request") + self.assertIsNone(err.type) + self.assertEqual(err.message, "model is required") + self.assertIsNone(err.request_id) + self.assertIsNone(err.retry_after) + + def test_non_json_error_body(self): + def handle(request): + return httpx.Response(502, text="Bad Gateway", headers={"x-request-id": "req_502", "retry-after": "30"}) + + with make_client(handle) as client: + with self.assertRaises(TokenLabError) as ctx: + client.get_models_json() + err = ctx.exception + self.assertEqual(err.status_code, 502) + self.assertIsNone(err.code) + self.assertIsNone(err.type) + self.assertEqual(err.message, "Bad Gateway") + self.assertEqual(err.request_id, "req_502") + self.assertEqual(err.retry_after, 30.0) + self.assertEqual(str(err), "TokenLab request failed: Bad Gateway") + + def test_non_json_error_body_without_retry_after(self): + def handle(request): + return httpx.Response(500, text="") + + with make_client(handle) as client: + with self.assertRaises(TokenLabError) as ctx: + client.get_pricing_json() + err = ctx.exception + self.assertEqual(err.status_code, 500) + self.assertEqual(err.message, "") + self.assertIsNone(err.retry_after) + self.assertTrue(str(err).startswith("TokenLab request failed:")) + + def test_unparseable_retry_after_is_none(self): + def handle(request): + return httpx.Response(503, json={"error": {"message": "busy"}}, headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}) + + with make_client(handle) as client: + with self.assertRaises(TokenLabError) as ctx: + client.list_pricing() + self.assertIsNone(ctx.exception.retry_after) + self.assertEqual(ctx.exception.message, "busy") + + def test_success_still_returns_json(self): + def handle(request): + return httpx.Response(200, json={"data": []}) + + with make_client(handle) as client: + self.assertEqual(client.list_models(), {"data": []}) + + +if __name__ == "__main__": + unittest.main()