Skip to content

Commit 1b85e28

Browse files
committed
Add BubbleAPIError with centralized error handling for cleaner API and better error details
1 parent fa0ab3c commit 1b85e28

7 files changed

Lines changed: 96 additions & 36 deletions

File tree

src/bubble_data_api_client/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
set_config_provider,
2020
)
2121
from bubble_data_api_client.constraints import Constraint, ConstraintType, constraint
22+
from bubble_data_api_client.exceptions import BubbleAPIError
2223
from bubble_data_api_client.pool import client_scope, close_clients
2324
from bubble_data_api_client.types import (
2425
BubbleField,
@@ -38,6 +39,8 @@
3839
# client classes
3940
"BubbleModel",
4041
"RawClient",
42+
# exceptions
43+
"BubbleAPIError",
4144
# query building
4245
"Constraint",
4346
"ConstraintType",

src/bubble_data_api_client/client/orm.py

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,12 @@ class User(BubbleModel, typename="user"):
1919
from collections.abc import AsyncIterator
2020
from datetime import datetime
2121

22-
import httpx
2322
from pydantic import BaseModel as PydanticBaseModel
2423
from pydantic import Field
2524

2625
from bubble_data_api_client.client.raw_client import AdditionalSortField, RawClient
2726
from bubble_data_api_client.constraints import Constraint, ConstraintType, constraint
28-
from bubble_data_api_client.exceptions import UnknownFieldError
27+
from bubble_data_api_client.exceptions import BubbleAPIError, UnknownFieldError
2928
from bubble_data_api_client.types import BubbleField, OnMultiple
3029

3130

@@ -91,7 +90,6 @@ async def create(cls, **data: typing.Any) -> typing.Self:
9190
aliased_data = cls._resolve_aliases(data)
9291
async with _get_client() as client:
9392
response = await client.create(cls._typename, aliased_data)
94-
response.raise_for_status()
9593
uid = response.json()["id"]
9694
return cls(**aliased_data, **{BubbleField.ID: uid})
9795

@@ -101,10 +99,9 @@ async def get(cls, uid: str) -> typing.Self | None:
10199
async with _get_client() as client:
102100
try:
103101
response = await client.retrieve(cls._typename, uid)
104-
response.raise_for_status()
105102
return cls(**response.json()["response"])
106-
except httpx.HTTPStatusError as e:
107-
if e.response.status_code == http.HTTPStatus.NOT_FOUND:
103+
except BubbleAPIError as e:
104+
if e.status_code == http.HTTPStatus.NOT_FOUND:
108105
return None
109106
raise
110107

@@ -130,22 +127,19 @@ async def save(self) -> None:
130127
exclude={"uid", "created_date", "modified_date", "slug"},
131128
by_alias=True,
132129
)
133-
response = await client.update(self._typename, self.uid, data)
134-
response.raise_for_status()
130+
await client.update(self._typename, self.uid, data)
135131

136132
@classmethod
137133
async def update(cls, uid: str, **data: typing.Any) -> None:
138134
"""Update specific fields on a thing by its unique ID."""
139135
aliased_data = cls._resolve_aliases(data)
140136
async with _get_client() as client:
141-
response = await client.update(cls._typename, uid, aliased_data)
142-
response.raise_for_status()
137+
await client.update(cls._typename, uid, aliased_data)
143138

144139
async def delete(self) -> None:
145140
"""Delete this thing from Bubble."""
146141
async with _get_client() as client:
147-
response = await client.delete(self._typename, self.uid)
148-
response.raise_for_status()
142+
await client.delete(self._typename, self.uid)
149143

150144
@classmethod
151145
async def find(
@@ -184,7 +178,6 @@ async def find(
184178
exclude_remaining=exclude_remaining,
185179
additional_sort_fields=additional_sort_fields,
186180
)
187-
response.raise_for_status()
188181
return [cls(**item) for item in response.json()["response"]["results"]]
189182

190183
@classmethod
@@ -210,7 +203,6 @@ async def find_iter(
210203
descending=descending,
211204
additional_sort_fields=additional_sort_fields,
212205
)
213-
response.raise_for_status()
214206
body = response.json()["response"]
215207
for item in body["results"]:
216208
yield cls(**item)

src/bubble_data_api_client/client/raw_client.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313
import httpx
1414

1515
from bubble_data_api_client.constraints import Constraint, ConstraintType, constraint
16-
from bubble_data_api_client.exceptions import InvalidOnMultipleError, MultipleMatchesError, PartialFailureError
16+
from bubble_data_api_client.exceptions import (
17+
BubbleAPIError,
18+
InvalidOnMultipleError,
19+
MultipleMatchesError,
20+
PartialFailureError,
21+
)
1722
from bubble_data_api_client.transport import Transport
1823
from bubble_data_api_client.types import BubbleField, CreateOrUpdateResult, OnMultiple
1924

@@ -142,8 +147,8 @@ async def exists(
142147
# ID lookup: retrieve + 404 is optimal (no JSON parsing needed)
143148
try:
144149
await self.retrieve(typename, uid)
145-
except httpx.HTTPStatusError as e:
146-
if e.response.status_code == http.HTTPStatus.NOT_FOUND:
150+
except BubbleAPIError as e:
151+
if e.status_code == http.HTTPStatus.NOT_FOUND:
147152
return False
148153
raise
149154
else:
@@ -223,16 +228,14 @@ async def create_or_update(
223228
if not results:
224229
merged_create_data = {**match, **(create_data or {})}
225230
response = await self.create(typename=typename, data=merged_create_data)
226-
response.raise_for_status()
227231
uid: str = response.json()["id"]
228232
return {"uids": [uid], "created": True}
229233

230234
# single match: update it (or skip if no update_data)
231235
if len(results) == 1:
232236
uid = results[0][BubbleField.ID]
233237
if update_data:
234-
response = await self.update(typename=typename, uid=uid, data=update_data)
235-
response.raise_for_status()
238+
await self.update(typename=typename, uid=uid, data=update_data)
236239
return {"uids": [uid], "created": False}
237240

238241
# multiple matches: handle according to strategy
@@ -243,8 +246,7 @@ async def create_or_update(
243246
case OnMultiple.UPDATE_FIRST:
244247
uid = results[0][BubbleField.ID]
245248
if update_data:
246-
response = await self.update(typename=typename, uid=uid, data=update_data)
247-
response.raise_for_status()
249+
await self.update(typename=typename, uid=uid, data=update_data)
248250
return {"uids": [uid], "created": False}
249251

250252
case OnMultiple.UPDATE_ALL:
@@ -263,7 +265,6 @@ async def create_or_update(
263265
if isinstance(item, BaseException):
264266
failed.append((uid, item))
265267
else:
266-
item.raise_for_status()
267268
succeeded.append(uid)
268269

269270
if failed:
@@ -286,8 +287,7 @@ async def create_or_update(
286287

287288
# update first so data is preserved even if deletes fail
288289
if update_data:
289-
response = await self.update(typename=typename, uid=keep_uid, data=update_data)
290-
response.raise_for_status()
290+
await self.update(typename=typename, uid=keep_uid, data=update_data)
291291

292292
# delete duplicates concurrently, letting all complete before checking errors
293293
delete_results = await asyncio.gather(
@@ -301,7 +301,6 @@ async def create_or_update(
301301
if isinstance(item, BaseException):
302302
failed.append((uid, item))
303303
else:
304-
item.raise_for_status()
305304
succeeded.append(uid)
306305

307306
if failed:

src/bubble_data_api_client/exceptions.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""Exception types for Bubble Data API errors."""
22

3+
import typing
4+
5+
import httpx
6+
37

48
class BubbleError(Exception):
59
"""Base class for all exceptions raised by the library."""
@@ -17,6 +21,52 @@ class BubbleHttpError(BubbleError):
1721
"""Base class for all high level HTTP errors."""
1822

1923

24+
class BubbleAPIError(BubbleHttpError):
25+
"""Structured error from Bubble API responses.
26+
27+
Attributes:
28+
status_code: HTTP status code (400, 404, 500, etc.)
29+
status: Bubble error status string ("MISSING_DATA", etc.) or None if unparseable
30+
message: Human-readable error message from Bubble or raw response text
31+
response: Original httpx.Response for advanced inspection
32+
"""
33+
34+
def __init__(
35+
self,
36+
status_code: int,
37+
status: str | None,
38+
message: str,
39+
response: httpx.Response,
40+
) -> None:
41+
"""Create error with parsed Bubble API response data."""
42+
self.status_code = status_code
43+
self.status = status
44+
self.message = message
45+
self.response = response
46+
super().__init__(message)
47+
48+
@classmethod
49+
def from_response(cls, response: httpx.Response) -> typing.Self:
50+
"""Parse Bubble error response and construct exception."""
51+
status: str | None = None
52+
message: str = response.text
53+
54+
try:
55+
data = response.json()
56+
body = data.get("body", {})
57+
status = body.get("status")
58+
message = body.get("message", response.text)
59+
except Exception: # noqa: S110
60+
pass # fall back to raw response text
61+
62+
return cls(
63+
status_code=response.status_code,
64+
status=status,
65+
message=message,
66+
response=response,
67+
)
68+
69+
2070
class BubbleUnauthorizedError(BubbleHttpError):
2171
"""Raised when the user is not authorized to access a resource."""
2272

src/bubble_data_api_client/transport.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import httpx
77

88
from bubble_data_api_client.config import get_config
9+
from bubble_data_api_client.exceptions import BubbleAPIError
910
from bubble_data_api_client.pool import get_client
1011

1112

@@ -15,7 +16,11 @@ class Transport:
1516
Responsibilities:
1617
- Obtains a pooled httpx client on entry
1718
- Provides HTTP verb methods (get, post, patch, put, delete)
18-
- Raises on non-2xx responses
19+
- Raises BubbleAPIError on non-2xx responses (single point of HTTP error handling)
20+
21+
All HTTP operations in this library flow through Transport.request(), which is
22+
the only place that checks response status and raises errors. Higher layers
23+
(RawClient, ORM) can assume that if a response is returned, it was successful.
1924
2025
HTTP client configuration (headers, retries, timeouts) is handled by
2126
the http_client module. Connection pooling is handled by the pool module.
@@ -49,7 +54,14 @@ async def request(
4954
params: dict[str, str] | None = None,
5055
headers: dict[str, str] | None = None,
5156
) -> httpx.Response:
52-
"""Execute an HTTP request with optional retry logic."""
57+
"""Execute an HTTP request with optional retry logic.
58+
59+
This is the single point of HTTP error handling for the library.
60+
Non-2xx responses are converted to BubbleAPIError before returning.
61+
62+
Raises:
63+
BubbleAPIError: On any non-2xx HTTP response.
64+
"""
5365

5466
async def do_request() -> httpx.Response:
5567
response: httpx.Response = await self._http.request(
@@ -60,7 +72,10 @@ async def do_request() -> httpx.Response:
6072
params=params,
6173
headers=headers,
6274
)
63-
response.raise_for_status()
75+
try:
76+
response.raise_for_status()
77+
except httpx.HTTPStatusError as e:
78+
raise BubbleAPIError.from_response(response) from e
6479
return response
6580

6681
retry = get_config().get("retry")

src/tests/unit/client/test_raw_client.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import pytest
33
import respx
44

5+
from bubble_data_api_client import BubbleAPIError
56
from bubble_data_api_client.client import raw_client
67

78

@@ -147,10 +148,10 @@ async def test_exists_by_uid_error_reraises(configured_client: None) -> None:
147148
)
148149

149150
async with raw_client.RawClient() as client:
150-
with pytest.raises(httpx.HTTPStatusError) as exc_info:
151+
with pytest.raises(BubbleAPIError) as exc_info:
151152
await client.exists(typename="customer", uid="123x456")
152153

153-
assert exc_info.value.response.status_code == 500
154+
assert exc_info.value.status_code == 500
154155

155156

156157
@respx.mock

src/tests/unit/test_transport.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import respx
66
import tenacity
77

8-
from bubble_data_api_client import configure, http_client
8+
from bubble_data_api_client import BubbleAPIError, configure, http_client
99
from bubble_data_api_client.pool import close_clients
1010
from bubble_data_api_client.transport import Transport
1111

@@ -42,9 +42,9 @@ async def test_transport_no_retry_fails_immediately(clean_client_pool: None) ->
4242
route = respx.get("https://test.example.com/test").mock(return_value=httpx.Response(500))
4343

4444
async with Transport() as transport:
45-
with pytest.raises(httpx.HTTPStatusError) as exc_info:
45+
with pytest.raises(BubbleAPIError) as exc_info:
4646
await transport.get("/test")
47-
assert exc_info.value.response.status_code == 500
47+
assert exc_info.value.status_code == 500
4848

4949
assert route.call_count == 1
5050

@@ -58,7 +58,7 @@ async def test_transport_retry_succeeds_after_failures(clean_client_pool: None)
5858
retry=tenacity.AsyncRetrying(
5959
stop=tenacity.stop_after_attempt(3),
6060
wait=tenacity.wait_none(),
61-
retry=tenacity.retry_if_exception_type(httpx.HTTPStatusError),
61+
retry=tenacity.retry_if_exception_type(BubbleAPIError),
6262
),
6363
)
6464

@@ -86,7 +86,7 @@ async def test_transport_retry_exhausted(clean_client_pool: None) -> None:
8686
retry=tenacity.AsyncRetrying(
8787
stop=tenacity.stop_after_attempt(3),
8888
wait=tenacity.wait_none(),
89-
retry=tenacity.retry_if_exception_type(httpx.HTTPStatusError),
89+
retry=tenacity.retry_if_exception_type(BubbleAPIError),
9090
),
9191
)
9292

0 commit comments

Comments
 (0)