Skip to content

Commit f4ae226

Browse files
Mlaz-codeclaude
andcommitted
feat(errors): align error codes with Go canonical list
Expand the error registry to cover all 19 HTTP + 6 WebSocket-frame codes now defined in the canonical error-code set. Exception routing now consults a code→exception map first and falls back to HTTP-status routing for responses without codes. Keep the existing six exception classes and retire no public API. bad_request and invalid_request are aliased to validation_error via DEPRECATED_CODE_ALIASES so servers still emitting them route through canonical_code() without breaking older clients. Bump 0.2.2 → 0.2.3. 87/87 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 46b6ff6 commit f4ae226

4 files changed

Lines changed: 168 additions & 7 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "sharpapi"
7-
version = "0.2.2"
7+
version = "0.2.3"
88
description = "Official Python SDK for the SharpAPI real-time sports betting odds API"
99
readme = "README.md"
1010
license = "MIT"

src/sharpapi/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
from .async_client import AsyncSharpAPI
2222
from .client import SharpAPI
2323
from .exceptions import (
24+
ERROR_CODE_DESCRIPTIONS,
25+
ERROR_CODE_TO_EXCEPTION,
2426
AuthenticationError,
2527
RateLimitedError,
2628
SharpAPIError,
@@ -51,7 +53,7 @@
5153
)
5254
from .streaming import EventStream
5355

54-
__version__ = "0.2.1"
56+
__version__ = "0.2.3"
5557

5658
__all__ = [
5759
# Clients
@@ -86,6 +88,9 @@
8688
"StreamError",
8789
"TierRestrictedError",
8890
"ValidationError",
91+
# Error-code registry
92+
"ERROR_CODE_DESCRIPTIONS",
93+
"ERROR_CODE_TO_EXCEPTION",
8994
# Utilities
9095
"american_to_decimal",
9196
"american_to_probability",

src/sharpapi/_base.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,19 @@
77
import httpx
88

99
from .exceptions import (
10+
ERROR_CODE_TO_EXCEPTION,
1011
AuthenticationError,
1112
RateLimitedError,
1213
SharpAPIError,
1314
TierRestrictedError,
1415
ValidationError,
16+
canonical_code,
1517
)
1618
from .models import APIResponse, RateLimitInfo, ResponseMeta
1719

1820
DEFAULT_BASE_URL = "https://api.sharpapi.io"
1921
DEFAULT_TIMEOUT = 30.0
20-
USER_AGENT = "sharpapi-python/0.2.2"
22+
USER_AGENT = "sharpapi-python/0.2.3"
2123

2224
RETRY_STATUSES = frozenset({502, 503, 504})
2325
RETRY_MAX_ATTEMPTS = 3
@@ -90,6 +92,30 @@ def handle_errors(response: httpx.Response) -> None:
9092
code = body.get("code", "unknown_error")
9193
status = response.status_code
9294

95+
# Resolve deprecated code aliases (bad_request, invalid_request → validation_error).
96+
code = canonical_code(code)
97+
98+
# Prefer the canonical code→exception mapping for well-known codes; fall back
99+
# to HTTP-status-based routing for responses that omit an error code.
100+
exc_class = ERROR_CODE_TO_EXCEPTION.get(code or "")
101+
if exc_class is TierRestrictedError:
102+
raise TierRestrictedError(
103+
error_msg,
104+
code=code,
105+
status=status,
106+
required_tier=body.get("required_tier"),
107+
)
108+
if exc_class is RateLimitedError:
109+
raise RateLimitedError(
110+
error_msg,
111+
code=code,
112+
status=status,
113+
retry_after=body.get("retry_after"),
114+
)
115+
if exc_class is not None and exc_class is not SharpAPIError:
116+
raise exc_class(error_msg, code=code, status=status)
117+
118+
# No canonical code match — route by HTTP status.
93119
if status == 401:
94120
raise AuthenticationError(error_msg, code=code, status=status)
95121
elif status == 403:

src/sharpapi/exceptions.py

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
"""SharpAPI exceptions."""
1+
"""SharpAPI exceptions and canonical error-code registry.
2+
3+
The error codes here mirror ``pkg/errcodes/errcodes.go`` in sharp-api-go, which
4+
is the single source of truth for every code the API emits. Keep this file in
5+
sync when new codes are added upstream.
6+
"""
27

38
from __future__ import annotations
49

@@ -13,7 +18,12 @@ def __init__(self, message: str, code: str | None = None, status: int | None = N
1318

1419

1520
class AuthenticationError(SharpAPIError):
16-
"""API key is missing or invalid (401)."""
21+
"""API key is missing, invalid, expired, disabled, or token is rejected.
22+
23+
Raised for HTTP 401 responses and any of the auth-related error codes
24+
(``missing_api_key``, ``invalid_api_key``, ``expired_api_key``,
25+
``disabled_api_key``, ``invalid_token``, ``unauthorized``).
26+
"""
1727

1828

1929
class TierRestrictedError(SharpAPIError):
@@ -31,7 +41,7 @@ def __init__(
3141

3242

3343
class RateLimitedError(SharpAPIError):
34-
"""Too many requests (429)."""
44+
"""Too many requests (429) — rate-limited, backpressure, or concurrent cap."""
3545

3646
def __init__(
3747
self,
@@ -49,4 +59,124 @@ class ValidationError(SharpAPIError):
4959

5060

5161
class StreamError(SharpAPIError):
52-
"""Error during SSE streaming."""
62+
"""Error during SSE or WebSocket streaming."""
63+
64+
65+
# =============================================================================
66+
# Canonical error-code registry
67+
#
68+
# Mirrors sharp-api-go/pkg/errcodes/errcodes.go. When upstream adds a new code,
69+
# add it here too and update the matching description. Each code maps to the
70+
# Python exception class that ``handle_errors`` (in ``_base.py``) raises for it.
71+
# =============================================================================
72+
73+
# HTTP error codes — emitted via REST handlers (httputil.WriteJSONError).
74+
BACKPRESSURE = "backpressure"
75+
CONCURRENT_REQUEST_CAP = "concurrent_request_cap"
76+
DISABLED_API_KEY = "disabled_api_key"
77+
EXPIRED_API_KEY = "expired_api_key"
78+
GONE = "gone"
79+
INTERNAL_ERROR = "internal_error"
80+
INVALID_API_KEY = "invalid_api_key"
81+
INVALID_TOKEN = "invalid_token"
82+
METHOD_NOT_ALLOWED = "method_not_allowed"
83+
MISSING_API_KEY = "missing_api_key"
84+
NOT_FOUND = "not_found"
85+
RATE_LIMITED = "rate_limited"
86+
SERVICE_UNAVAILABLE = "service_unavailable"
87+
TIER_RESTRICTED = "tier_restricted"
88+
TOO_MANY_STREAMS = "too_many_streams"
89+
UNAUTHORIZED = "unauthorized"
90+
UNKNOWN_ENDPOINT = "unknown_endpoint"
91+
UPSTREAM_ERROR = "upstream_error"
92+
VALIDATION_ERROR = "validation_error"
93+
94+
# WebSocket frame error codes — emitted in "error" message frames.
95+
WS_ALREADY_AUTHENTICATED = "already_authenticated"
96+
WS_INVALID_MESSAGE = "invalid_message"
97+
WS_MISSING_CHANNELS = "missing_channels"
98+
WS_MISSING_TOKEN = "missing_token"
99+
WS_NOT_AUTHENTICATED = "not_authenticated"
100+
WS_UNKNOWN_MESSAGE_TYPE = "unknown_message_type"
101+
102+
#: Human-readable descriptions for every canonical code.
103+
ERROR_CODE_DESCRIPTIONS: dict[str, str] = {
104+
# HTTP
105+
BACKPRESSURE: "Server is shedding load; retry shortly.",
106+
CONCURRENT_REQUEST_CAP: "Too many in-flight requests for this API key.",
107+
DISABLED_API_KEY: "API key has been disabled.",
108+
EXPIRED_API_KEY: "API key has expired.",
109+
GONE: "Resource is no longer available.",
110+
INTERNAL_ERROR: "Unexpected server error.",
111+
INVALID_API_KEY: "API key is invalid.",
112+
INVALID_TOKEN: "Bearer token is invalid or malformed.",
113+
METHOD_NOT_ALLOWED: "HTTP method not allowed on this endpoint.",
114+
MISSING_API_KEY: "No API key provided.",
115+
NOT_FOUND: "Resource not found.",
116+
RATE_LIMITED: "Rate limit exceeded; see Retry-After header.",
117+
SERVICE_UNAVAILABLE: "Service is temporarily unavailable.",
118+
TIER_RESTRICTED: "Current subscription tier does not include this feature.",
119+
TOO_MANY_STREAMS: "Maximum concurrent WebSocket/SSE streams exceeded.",
120+
UNAUTHORIZED: "Authentication required.",
121+
UNKNOWN_ENDPOINT: "Endpoint does not exist.",
122+
UPSTREAM_ERROR: "Upstream data source error.",
123+
VALIDATION_ERROR: "Request parameters failed validation.",
124+
# WebSocket
125+
WS_ALREADY_AUTHENTICATED: "Auth frame sent on an already-authenticated connection.",
126+
WS_INVALID_MESSAGE: "Malformed WebSocket frame.",
127+
WS_MISSING_CHANNELS: "Subscribe frame had no channels.",
128+
WS_MISSING_TOKEN: "Auth frame had no token.",
129+
WS_NOT_AUTHENTICATED: "Action requires authentication first.",
130+
WS_UNKNOWN_MESSAGE_TYPE: "Unknown WebSocket message type.",
131+
}
132+
133+
#: Map each canonical code to the SharpAPIError subclass ``handle_errors`` raises.
134+
ERROR_CODE_TO_EXCEPTION: dict[str, type[SharpAPIError]] = {
135+
# Auth family → AuthenticationError
136+
MISSING_API_KEY: AuthenticationError,
137+
INVALID_API_KEY: AuthenticationError,
138+
EXPIRED_API_KEY: AuthenticationError,
139+
DISABLED_API_KEY: AuthenticationError,
140+
INVALID_TOKEN: AuthenticationError,
141+
UNAUTHORIZED: AuthenticationError,
142+
# Tier
143+
TIER_RESTRICTED: TierRestrictedError,
144+
# Rate / load shedding
145+
RATE_LIMITED: RateLimitedError,
146+
BACKPRESSURE: RateLimitedError,
147+
CONCURRENT_REQUEST_CAP: RateLimitedError,
148+
TOO_MANY_STREAMS: RateLimitedError,
149+
# Validation
150+
VALIDATION_ERROR: ValidationError,
151+
# Streaming frames
152+
WS_ALREADY_AUTHENTICATED: StreamError,
153+
WS_INVALID_MESSAGE: StreamError,
154+
WS_MISSING_CHANNELS: StreamError,
155+
WS_MISSING_TOKEN: StreamError,
156+
WS_NOT_AUTHENTICATED: StreamError,
157+
WS_UNKNOWN_MESSAGE_TYPE: StreamError,
158+
# Everything else falls through to SharpAPIError
159+
GONE: SharpAPIError,
160+
INTERNAL_ERROR: SharpAPIError,
161+
METHOD_NOT_ALLOWED: SharpAPIError,
162+
NOT_FOUND: SharpAPIError,
163+
SERVICE_UNAVAILABLE: SharpAPIError,
164+
UNKNOWN_ENDPOINT: SharpAPIError,
165+
UPSTREAM_ERROR: SharpAPIError,
166+
}
167+
168+
# Deprecated aliases. ``bad_request`` and ``invalid_request`` were both collapsed
169+
# into ``validation_error`` in sharp-api-go. Kept here so that older API
170+
# responses (or user code still checking these strings) resolve correctly.
171+
# TODO: remove after 2026-10.
172+
DEPRECATED_CODE_ALIASES: dict[str, str] = {
173+
"bad_request": VALIDATION_ERROR,
174+
"invalid_request": VALIDATION_ERROR,
175+
}
176+
177+
178+
def canonical_code(code: str | None) -> str | None:
179+
"""Return the canonical code, resolving deprecated aliases."""
180+
if code is None:
181+
return None
182+
return DEPRECATED_CODE_ALIASES.get(code, code)

0 commit comments

Comments
 (0)