Skip to content
Open
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 2 additions & 1 deletion src/tokenlab/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
74 changes: 73 additions & 1 deletion src/tokenlab/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down
109 changes: 109 additions & 0 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
@@ -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()