Skip to content

Commit 0780735

Browse files
committed
improved less-leaky error handling
1 parent cc975ed commit 0780735

3 files changed

Lines changed: 74 additions & 40 deletions

File tree

src/stackcoin/client.py

Lines changed: 55 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,38 @@ async def close(self) -> None:
8383
"""Close the underlying HTTP connection pool."""
8484
await self._http.aclose()
8585

86+
async def _request(
87+
self,
88+
method: str,
89+
url: str,
90+
*,
91+
params: dict[str, Any] | None = None,
92+
json: Any = None,
93+
headers: dict[str, str] | None = None,
94+
) -> httpx.Response:
95+
"""Issue an HTTP request to StackCoin, normalising every failure mode.
96+
97+
Any transport-level fault (DNS, connection refused, timeout, network
98+
reset, etc.) is wrapped in a ``StackCoinError`` with ``status_code=0``
99+
and ``error="transport_error"`` so callers only ever need to catch
100+
:class:`StackCoinError` — never raw ``httpx`` exceptions.
101+
102+
Non-2xx responses are mapped to ``StackCoinError`` via
103+
:meth:`_raise_for_error` as before.
104+
"""
105+
try:
106+
resp = await self._http.request(
107+
method, url, params=params, json=json, headers=headers
108+
)
109+
except httpx.HTTPError as e:
110+
raise StackCoinError(
111+
StackCoinError.TRANSPORT_STATUS,
112+
StackCoinError.TRANSPORT_ERROR,
113+
repr(e),
114+
) from e
115+
self._raise_for_error(resp)
116+
return resp
117+
86118
@staticmethod
87119
def _raise_for_error(resp: httpx.Response) -> None:
88120
"""Raise :class:`StackCoinError` on any 4xx/5xx response."""
@@ -97,23 +129,20 @@ def _raise_for_error(resp: httpx.Response) -> None:
97129

98130
async def get_me(self) -> User:
99131
"""Return the authenticated user's profile."""
100-
resp = await self._http.get("/api/user/me")
101-
self._raise_for_error(resp)
132+
resp = await self._request("GET", "/api/user/me")
102133
return User.model_validate(resp.json())
103134

104135
async def get_user(self, user_id: int) -> User:
105136
"""Return a user by their ID."""
106-
resp = await self._http.get(f"/api/user/{user_id}")
107-
self._raise_for_error(resp)
137+
resp = await self._request("GET", f"/api/user/{user_id}")
108138
return User.model_validate(resp.json())
109139

110140
async def get_users(self, *, discord_id: str | None = None) -> list[User]:
111141
"""Return a list of users, optionally filtered by Discord ID."""
112142
params: dict[str, Any] = {}
113143
if discord_id is not None:
114144
params["discord_id"] = discord_id
115-
resp = await self._http.get("/api/users", params=params)
116-
self._raise_for_error(resp)
145+
resp = await self._request("GET", "/api/users", params=params)
117146
wrapper = UsersResponse.model_validate(resp.json())
118147
return wrapper.users or []
119148

@@ -132,12 +161,12 @@ async def send(
132161
headers: dict[str, str] = {}
133162
if idempotency_key is not None:
134163
headers["Idempotency-Key"] = idempotency_key
135-
resp = await self._http.post(
164+
resp = await self._request(
165+
"POST",
136166
f"/api/user/{to_user_id}/send",
137167
json=body,
138168
headers=headers,
139169
)
140-
self._raise_for_error(resp)
141170
return SendStkResponse.model_validate(resp.json())
142171

143172
async def create_request(
@@ -158,12 +187,12 @@ async def create_request(
158187
headers: dict[str, str] = {}
159188
if idempotency_key is not None:
160189
headers["Idempotency-Key"] = idempotency_key
161-
resp = await self._http.post(
190+
resp = await self._request(
191+
"POST",
162192
f"/api/user/{to_user_id}/request",
163193
json=body,
164194
headers=headers,
165195
)
166-
self._raise_for_error(resp)
167196
return CreateRequestResponse.model_validate(resp.json())
168197

169198
async def create_preauth(
@@ -173,73 +202,64 @@ async def create_preauth(
173202
window_hours: int,
174203
) -> dict:
175204
"""Request a preauthorization from a user."""
176-
resp = await self._http.post(
205+
resp = await self._request(
206+
"POST",
177207
f"/api/user/{user_id}/preauth",
178208
json={"max_amount": max_amount, "window_hours": window_hours},
179209
)
180-
self._raise_for_error(resp)
181210
return resp.json()
182211

183212
async def get_preauth(self, preauth_id: int) -> dict:
184213
"""Get a single preauthorization with remaining budget."""
185-
resp = await self._http.get(f"/api/preauth/{preauth_id}")
186-
self._raise_for_error(resp)
214+
resp = await self._request("GET", f"/api/preauth/{preauth_id}")
187215
return resp.json()
188216

189217
async def revoke_preauth(self, preauth_id: int) -> dict:
190218
"""Revoke an active preauthorization."""
191-
resp = await self._http.post(f"/api/preauth/{preauth_id}/revoke")
192-
self._raise_for_error(resp)
219+
resp = await self._request("POST", f"/api/preauth/{preauth_id}/revoke")
193220
return resp.json()
194221

195222
async def get_preauths(self, *, user_id: int | None = None) -> list[dict]:
196223
"""List preauths for this bot, optionally filtered by user_id."""
197224
params: dict[str, Any] = {}
198225
if user_id is not None:
199226
params["user_id"] = user_id
200-
resp = await self._http.get("/api/preauths", params=params)
201-
self._raise_for_error(resp)
227+
resp = await self._request("GET", "/api/preauths", params=params)
202228
return resp.json().get("preauths", [])
203229

204230
async def get_request(self, request_id: int) -> Request:
205231
"""Return a single request by its ID."""
206-
resp = await self._http.get(f"/api/request/{request_id}")
207-
self._raise_for_error(resp)
232+
resp = await self._request("GET", f"/api/request/{request_id}")
208233
return Request.model_validate(resp.json())
209234

210235
async def get_requests(self, *, status: str | None = None) -> list[Request]:
211236
"""Return requests for the authenticated user, optionally filtered by status."""
212237
params: dict[str, Any] = {}
213238
if status is not None:
214239
params["status"] = status
215-
resp = await self._http.get("/api/requests", params=params)
216-
self._raise_for_error(resp)
240+
resp = await self._request("GET", "/api/requests", params=params)
217241
wrapper = RequestsResponse.model_validate(resp.json())
218242
return wrapper.requests or []
219243

220244
async def accept_request(self, request_id: int) -> RequestActionResponse:
221245
"""Accept a pending STK request."""
222-
resp = await self._http.post(f"/api/requests/{request_id}/accept")
223-
self._raise_for_error(resp)
246+
resp = await self._request("POST", f"/api/requests/{request_id}/accept")
224247
return RequestActionResponse.model_validate(resp.json())
225248

226249
async def deny_request(self, request_id: int) -> RequestActionResponse:
227250
"""Deny a pending STK request."""
228-
resp = await self._http.post(f"/api/requests/{request_id}/deny")
229-
self._raise_for_error(resp)
251+
resp = await self._request("POST", f"/api/requests/{request_id}/deny")
230252
return RequestActionResponse.model_validate(resp.json())
231253

232254
async def get_transactions(self) -> list[Transaction]:
233255
"""Return transactions for the authenticated user."""
234-
resp = await self._http.get("/api/transactions")
235-
self._raise_for_error(resp)
256+
resp = await self._request("GET", "/api/transactions")
236257
wrapper = TransactionsResponse.model_validate(resp.json())
237258
return wrapper.transactions or []
238259

239260
async def get_transaction(self, transaction_id: int) -> Transaction:
240261
"""Return a single transaction by its ID."""
241-
resp = await self._http.get(f"/api/transaction/{transaction_id}")
242-
self._raise_for_error(resp)
262+
resp = await self._request("GET", f"/api/transaction/{transaction_id}")
243263
return Transaction.model_validate(resp.json())
244264

245265
async def get_events(self, *, since_id: int = 0) -> list[AnyEvent]:
@@ -254,10 +274,10 @@ async def get_events(self, *, since_id: int = 0) -> list[AnyEvent]:
254274
params: dict[str, Any] = {}
255275
if cursor > 0:
256276
params["since_id"] = cursor
257-
resp = await self._http.get("/api/events", params=params)
258-
self._raise_for_error(resp)
277+
resp = await self._request("GET", "/api/events", params=params)
259278
wrapper = EventsResponse.model_validate(resp.json())
260279
page = [e.root for e in wrapper.events]
280+
261281
all_events.extend(page)
262282

263283
if not wrapper.has_more or not page:
@@ -268,20 +288,17 @@ async def get_events(self, *, since_id: int = 0) -> list[AnyEvent]:
268288

269289
async def get_discord_bot_id(self) -> str:
270290
"""Return the Discord user ID of the StackCoin bot."""
271-
resp = await self._http.get("/api/discord/bot")
272-
self._raise_for_error(resp)
291+
resp = await self._request("GET", "/api/discord/bot")
273292
bot = DiscordBotResponse.model_validate(resp.json())
274293
return bot.discord_id
275294

276295
async def get_discord_guilds(self) -> list[DiscordGuild]:
277296
"""Return all Discord guilds."""
278-
resp = await self._http.get("/api/discord/guilds")
279-
self._raise_for_error(resp)
297+
resp = await self._request("GET", "/api/discord/guilds")
280298
wrapper = DiscordGuildsResponse.model_validate(resp.json())
281299
return wrapper.guilds or []
282300

283301
async def get_discord_guild(self, snowflake: str) -> DiscordGuild:
284302
"""Return a single Discord guild by its snowflake ID."""
285-
resp = await self._http.get(f"/api/discord/guild/{snowflake}")
286-
self._raise_for_error(resp)
303+
resp = await self._request("GET", f"/api/discord/guild/{snowflake}")
287304
return DiscordGuild.model_validate(resp.json())

src/stackcoin/errors.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
class StackCoinError(Exception):
2-
"""Raised when the StackCoin API returns an error response."""
2+
"""Raised on any failure to reach or get a successful response from StackCoin.
3+
4+
Covers both transport-level failures (connection refused, DNS error, timeout,
5+
etc.) and non-2xx HTTP responses. Callers should never need to catch
6+
``httpx.HTTPError`` directly — anything that goes wrong talking to StackCoin
7+
is funnelled through this class.
8+
9+
Attributes:
10+
status_code: HTTP status code, or ``0`` for transport failures that never
11+
produced a response (``error == "transport_error"``).
12+
error: Short machine-readable code. ``"transport_error"`` for network faults,
13+
otherwise the StackCoin API's own ``error`` field (or ``http_<status>``).
14+
message: Human-readable detail, if available.
15+
"""
16+
17+
# Sentinel status code for failures that produced no HTTP response at all.
18+
TRANSPORT_STATUS: int = 0
19+
TRANSPORT_ERROR: str = "transport_error"
320

421
def __init__(self, status_code: int, error: str, message: str | None = None):
522
self.status_code = status_code

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)