Skip to content

Commit 3942828

Browse files
committed
[errors] prepare new errors model
1 parent 608e484 commit 3942828

File tree

5 files changed

+420
-39
lines changed

5 files changed

+420
-39
lines changed

android_sms_gateway/ahttp.py

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import abc
22
import typing as t
33

4+
from .errors import (
5+
error_from_status,
6+
)
7+
48

59
class AsyncHttpClient(t.Protocol):
610
@abc.abstractmethod
@@ -70,15 +74,31 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
7074
await self._session.close()
7175
self._session = None
7276

77+
async def _process_response(self, response: aiohttp.ClientResponse) -> dict:
78+
try:
79+
response.raise_for_status()
80+
return await response.json()
81+
except aiohttp.ClientResponseError as e:
82+
# Extract error message from response if available
83+
error_data = {}
84+
try:
85+
error_data = await response.json()
86+
except ValueError:
87+
# Response is not JSON
88+
pass
89+
90+
# Use the error mapping to create appropriate exception
91+
error_message = str(e) or "HTTP request failed"
92+
raise error_from_status(error_message, response.status, error_data)
93+
7394
async def get(
7495
self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None
7596
) -> dict:
7697
if self._session is None:
7798
raise ValueError("Session not initialized")
7899

79100
async with self._session.get(url, headers=headers) as response:
80-
response.raise_for_status()
81-
return await response.json()
101+
return await self._process_response(response)
82102

83103
async def post(
84104
self,
@@ -93,8 +113,7 @@ async def post(
93113
async with self._session.post(
94114
url, headers=headers, json=payload
95115
) as response:
96-
response.raise_for_status()
97-
return await response.json()
116+
return await self._process_response(response)
98117

99118
async def put(
100119
self,
@@ -109,8 +128,7 @@ async def put(
109128
async with self._session.put(
110129
url, headers=headers, json=payload
111130
) as response:
112-
response.raise_for_status()
113-
return await response.json()
131+
return await self._process_response(response)
114132

115133
async def patch(
116134
self,
@@ -125,8 +143,7 @@ async def patch(
125143
async with self._session.patch(
126144
url, headers=headers, json=payload
127145
) as response:
128-
response.raise_for_status()
129-
return await response.json()
146+
return await self._process_response(response)
130147

131148
async def delete(
132149
self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None
@@ -135,7 +152,7 @@ async def delete(
135152
raise ValueError("Session not initialized")
136153

137154
async with self._session.delete(url, headers=headers) as response:
138-
response.raise_for_status()
155+
await self._process_response(response)
139156

140157
DEFAULT_CLIENT = AiohttpAsyncHttpClient
141158
except ImportError:
@@ -163,15 +180,31 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
163180
await self._client.aclose()
164181
self._client = None
165182

183+
async def _process_response(self, response: httpx.Response) -> dict:
184+
try:
185+
response.raise_for_status()
186+
return response.json()
187+
except httpx.HTTPStatusError as e:
188+
# Extract error message from response if available
189+
error_data = {}
190+
try:
191+
error_data = response.json()
192+
except ValueError:
193+
# Response is not JSON
194+
pass
195+
196+
# Use the error mapping to create appropriate exception
197+
error_message = str(e) or "HTTP request failed"
198+
raise error_from_status(error_message, response.status_code, error_data)
199+
166200
async def get(
167201
self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None
168202
) -> dict:
169203
if self._client is None:
170204
raise ValueError("Client not initialized")
171205

172206
response = await self._client.get(url, headers=headers)
173-
174-
return response.raise_for_status().json()
207+
return await self._process_response(response)
175208

176209
async def post(
177210
self,
@@ -184,8 +217,7 @@ async def post(
184217
raise ValueError("Client not initialized")
185218

186219
response = await self._client.post(url, headers=headers, json=payload)
187-
188-
return response.raise_for_status().json()
220+
return await self._process_response(response)
189221

190222
async def put(
191223
self,
@@ -198,8 +230,7 @@ async def put(
198230
raise ValueError("Client not initialized")
199231

200232
response = await self._client.put(url, headers=headers, json=payload)
201-
202-
return response.raise_for_status().json()
233+
return await self._process_response(response)
203234

204235
async def patch(
205236
self,
@@ -212,8 +243,7 @@ async def patch(
212243
raise ValueError("Client not initialized")
213244

214245
response = await self._client.patch(url, headers=headers, json=payload)
215-
216-
return response.raise_for_status().json()
246+
return await self._process_response(response)
217247

218248
async def delete(
219249
self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None
@@ -222,7 +252,7 @@ async def delete(
222252
raise ValueError("Client not initialized")
223253

224254
response = await self._client.delete(url, headers=headers)
225-
response.raise_for_status()
255+
await self._process_response(response)
226256

227257
DEFAULT_CLIENT = HttpxAsyncHttpClient
228258
except ImportError:

android_sms_gateway/errors.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import typing as t
2+
3+
4+
class APIError(Exception):
5+
"""Base class for API exceptions."""
6+
7+
def __init__(
8+
self,
9+
message: str,
10+
status_code: t.Optional[int] = None,
11+
response: t.Optional[dict] = None,
12+
):
13+
super().__init__(message)
14+
self.status_code = status_code
15+
self.response = response
16+
17+
18+
class BadRequestError(APIError):
19+
"""400 - Bad Request"""
20+
21+
22+
class UnauthorizedError(APIError):
23+
"""401 - Unauthorized"""
24+
25+
26+
class ForbiddenError(APIError):
27+
"""403 - Forbidden"""
28+
29+
30+
class NotFoundError(APIError):
31+
"""404 - Not Found"""
32+
33+
34+
class InternalServerError(APIError):
35+
"""500 - Internal Server Error"""
36+
37+
38+
class ServiceUnavailableError(APIError):
39+
"""503 - Service Unavailable"""
40+
41+
42+
class GatewayTimeoutError(APIError):
43+
"""504 - Gateway Timeout"""
44+
45+
46+
_ERROR_MAP = {
47+
400: BadRequestError,
48+
401: UnauthorizedError,
49+
403: ForbiddenError,
50+
404: NotFoundError,
51+
500: InternalServerError,
52+
503: ServiceUnavailableError,
53+
504: GatewayTimeoutError,
54+
}
55+
56+
57+
def error_from_status(
58+
message: str, status: int, response: t.Optional[dict] = None
59+
) -> APIError:
60+
"""Factory function to map HTTP status codes to appropriate APIError subclasses.
61+
62+
Args:
63+
message: Error message
64+
status: HTTP status code
65+
response: Optional response data
66+
67+
Returns:
68+
Appropriate APIError subclass or APIError as fallback
69+
"""
70+
return _ERROR_MAP.get(status, APIError)(
71+
message, status_code=status, response=response
72+
)

android_sms_gateway/http.py

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
from . import client, domain
66

7+
from .errors import (
8+
error_from_status,
9+
)
10+
711

812
class HttpClient(t.Protocol):
913
@abc.abstractmethod
@@ -130,8 +134,21 @@ def patch(
130134
)
131135

132136
def _process_response(self, response: requests.Response) -> dict:
133-
response.raise_for_status()
134-
return response.json()
137+
try:
138+
response.raise_for_status()
139+
return response.json()
140+
except requests.exceptions.HTTPError as e:
141+
# Extract error message from response if available
142+
error_data = {}
143+
try:
144+
error_data = response.json()
145+
except ValueError:
146+
# Response is not JSON
147+
pass
148+
149+
# Use the error mapping to create appropriate exception
150+
error_message = str(e) or "HTTP request failed"
151+
raise error_from_status(error_message, response.status_code, error_data)
135152

136153
DEFAULT_CLIENT = RequestsHttpClient
137154
except ImportError:
@@ -159,13 +176,30 @@ def __exit__(self, exc_type, exc_val, exc_tb):
159176
self._client.close()
160177
self._client = None
161178

179+
def _process_response(self, response: httpx.Response) -> dict:
180+
try:
181+
response.raise_for_status()
182+
return response.json()
183+
except httpx.HTTPStatusError as e:
184+
# Extract error message from response if available
185+
error_data = {}
186+
try:
187+
error_data = response.json()
188+
except ValueError:
189+
# Response is not JSON
190+
pass
191+
192+
# Use the error mapping to create appropriate exception
193+
error_message = str(e) or "HTTP request failed"
194+
raise error_from_status(error_message, response.status_code, error_data)
195+
162196
def get(
163197
self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None
164198
) -> dict:
165199
if self._client is None:
166200
raise ValueError("Client not initialized")
167201

168-
return self._client.get(url, headers=headers).raise_for_status().json()
202+
return self._process_response(self._client.get(url, headers=headers))
169203

170204
def post(
171205
self,
@@ -177,10 +211,8 @@ def post(
177211
if self._client is None:
178212
raise ValueError("Client not initialized")
179213

180-
return (
214+
return self._process_response(
181215
self._client.post(url, headers=headers, json=payload)
182-
.raise_for_status()
183-
.json()
184216
)
185217

186218
def delete(
@@ -189,7 +221,7 @@ def delete(
189221
if self._client is None:
190222
raise ValueError("Client not initialized")
191223

192-
self._client.delete(url, headers=headers).raise_for_status()
224+
self._process_response(self._client.delete(url, headers=headers))
193225

194226
def put(
195227
self,
@@ -201,10 +233,8 @@ def put(
201233
if self._client is None:
202234
raise ValueError("Client not initialized")
203235

204-
return (
236+
return self._process_response(
205237
self._client.put(url, headers=headers, json=payload)
206-
.raise_for_status()
207-
.json()
208238
)
209239

210240
def patch(
@@ -217,10 +247,8 @@ def patch(
217247
if self._client is None:
218248
raise ValueError("Client not initialized")
219249

220-
return (
250+
return self._process_response(
221251
self._client.patch(url, headers=headers, json=payload)
222-
.raise_for_status()
223-
.json()
224252
)
225253

226254
DEFAULT_CLIENT = HttpxHttpClient

tests/test_client.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import os
22
import pytest
3-
from requests import HTTPError
43

54
from android_sms_gateway.client import APIClient
65
from android_sms_gateway.constants import DEFAULT_URL
76
from android_sms_gateway.domain import Webhook
87
from android_sms_gateway.enums import WebhookEvent
98
from android_sms_gateway.http import RequestsHttpClient
9+
from android_sms_gateway import errors
1010

1111

1212
@pytest.fixture
@@ -25,12 +25,15 @@ def client():
2525
2626
:yields: An instance of `APIClient`.
2727
"""
28-
with RequestsHttpClient() as h, APIClient(
29-
os.environ.get("API_LOGIN") or "test",
30-
os.environ.get("API_PASSWORD") or "test",
31-
base_url=os.environ.get("API_BASE_URL") or DEFAULT_URL,
32-
http=h,
33-
) as c:
28+
with (
29+
RequestsHttpClient() as h,
30+
APIClient(
31+
os.environ.get("API_LOGIN") or "test",
32+
os.environ.get("API_PASSWORD") or "test",
33+
base_url=os.environ.get("API_BASE_URL") or DEFAULT_URL,
34+
http=h,
35+
) as c,
36+
):
3437
yield c
3538

3639

@@ -75,7 +78,7 @@ def test_webhook_create_invalid_url(self, client: APIClient):
7578
7679
:param client: An instance of `APIClient`.
7780
"""
78-
with pytest.raises(HTTPError):
81+
with pytest.raises(errors.APIError):
7982
client.create_webhook(
8083
Webhook(None, url="not_a_url", event=WebhookEvent.SMS_RECEIVED)
8184
)

0 commit comments

Comments
 (0)