Skip to content

Commit ca5c8f6

Browse files
committed
add optional retry
1 parent 0dde7e8 commit ca5c8f6

5 files changed

Lines changed: 123 additions & 31 deletions

File tree

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
1-
from bubble_data_api_client.config import configure, set_config_provider
1+
from bubble_data_api_client.config import (
2+
BubbleConfig,
3+
ConfigProvider,
4+
configure,
5+
set_config_provider,
6+
)
27
from bubble_data_api_client.pool import client_scope, close_clients
38

4-
__all__ = ["configure", "set_config_provider", "client_scope", "close_clients"]
9+
__all__ = [
10+
"BubbleConfig",
11+
"ConfigProvider",
12+
"configure",
13+
"set_config_provider",
14+
"client_scope",
15+
"close_clients",
16+
]

src/bubble_data_api_client/config.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,6 @@
44
import tenacity
55

66

7-
class BubbleConfigError(RuntimeError):
8-
"""Raised when Bubble API is not configured."""
9-
10-
def __init__(self) -> None:
11-
super().__init__("Bubble API not configured. Call configure() first.")
12-
13-
147
class _NotSet:
158
"""Sentinel for configuration values that were not provided."""
169

@@ -39,7 +32,7 @@ class BubbleConfig(TypedDict):
3932

4033
type ConfigProvider = Callable[[], BubbleConfig]
4134

42-
_static_config: BubbleConfig | None = None
35+
_static_config: BubbleConfig = {"data_api_root_url": "", "api_key": ""}
4336
_config_provider: ConfigProvider | None = None
4437

4538

@@ -69,6 +62,4 @@ def get_config() -> BubbleConfig:
6962
"""Get current configuration from provider if set, otherwise static config."""
7063
if _config_provider is not None:
7164
return _config_provider()
72-
if _static_config is None:
73-
raise BubbleConfigError
7465
return _static_config

src/bubble_data_api_client/pool.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import asyncio
44
import atexit
55
import threading
6-
from collections.abc import AsyncIterator, Mapping
6+
from collections.abc import AsyncIterator
77
from contextlib import asynccontextmanager
88

99
import httpx
1010

11-
from bubble_data_api_client.config import get_config
11+
from bubble_data_api_client.config import BubbleConfig, get_config
1212
from bubble_data_api_client.exceptions import ConfigurationError
1313
from bubble_data_api_client.http_client import httpx_client_factory
1414

@@ -17,11 +17,9 @@
1717
_lock = threading.Lock()
1818

1919

20-
def _make_client_key(config: Mapping[str, str | None]) -> tuple[str, str]:
20+
def _make_client_key(config: BubbleConfig) -> tuple[str, str]:
2121
"""Generate a unique key for client pooling based on config."""
22-
base_url = config.get("data_api_root_url") or ""
23-
api_key = config.get("api_key") or ""
24-
return (base_url, api_key)
22+
return (config["data_api_root_url"], config["api_key"])
2523

2624

2725
def get_client() -> httpx.AsyncClient:
@@ -37,10 +35,10 @@ def get_client() -> httpx.AsyncClient:
3735
with _lock:
3836
# double-check after acquiring lock
3937
if key not in _clients:
40-
base_url = config.get("data_api_root_url")
38+
base_url = config["data_api_root_url"]
4139
if not base_url:
4240
raise ConfigurationError("data_api_root_url")
43-
api_key = config.get("api_key")
41+
api_key = config["api_key"]
4442
if not api_key:
4543
raise ConfigurationError("api_key")
4644
_clients[key] = httpx_client_factory(base_url=base_url, api_key=api_key)

src/bubble_data_api_client/transport.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import httpx
77

8+
from bubble_data_api_client.config import get_config
89
from bubble_data_api_client.pool import get_client
910

1011

@@ -47,16 +48,22 @@ async def request(
4748
params: dict[str, str] | None = None,
4849
headers: dict[str, str] | None = None,
4950
) -> httpx.Response:
50-
response: httpx.Response = await self._http.request(
51-
method=method,
52-
url=url,
53-
content=content,
54-
json=json,
55-
params=params,
56-
headers=headers,
57-
)
58-
response.raise_for_status()
59-
return response
51+
async def do_request() -> httpx.Response:
52+
response: httpx.Response = await self._http.request(
53+
method=method,
54+
url=url,
55+
content=content,
56+
json=json,
57+
params=params,
58+
headers=headers,
59+
)
60+
response.raise_for_status()
61+
return response
62+
63+
retry = get_config().get("retry")
64+
if retry is not None:
65+
return await retry(do_request)
66+
return await do_request()
6067

6168
async def get(
6269
self,

src/tests/unit/test_transport.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
1+
from collections.abc import AsyncGenerator
2+
13
import httpx
4+
import pytest
5+
import respx
6+
import tenacity
7+
8+
from bubble_data_api_client import configure, http_client
9+
from bubble_data_api_client.pool import close_clients
10+
from bubble_data_api_client.transport import Transport
11+
212

3-
from bubble_data_api_client import http_client
13+
@pytest.fixture
14+
async def clean_client_pool() -> AsyncGenerator[None]:
15+
"""Ensure client pool is clean before and after each test."""
16+
await close_clients()
17+
yield
18+
await close_clients()
419

520

621
def test_httpx_client_factory(test_url: str, test_api_key: str) -> None:
@@ -13,3 +28,72 @@ def test_httpx_client_factory(test_url: str, test_api_key: str) -> None:
1328
assert client.base_url == test_url
1429
assert client.headers["Authorization"] == f"Bearer {test_api_key}"
1530
assert client.headers["User-Agent"] == http_client.DEFAULT_USER_AGENT
31+
32+
33+
@respx.mock
34+
async def test_transport_no_retry_fails_immediately(clean_client_pool: None) -> None:
35+
"""Test that request fails immediately when no retry is configured."""
36+
configure(
37+
data_api_root_url="https://test.example.com",
38+
api_key="test-key",
39+
retry=None,
40+
)
41+
42+
route = respx.get("https://test.example.com/test").mock(return_value=httpx.Response(500))
43+
44+
async with Transport() as transport:
45+
with pytest.raises(httpx.HTTPStatusError) as exc_info:
46+
await transport.get("/test")
47+
assert exc_info.value.response.status_code == 500
48+
49+
assert route.call_count == 1
50+
51+
52+
@respx.mock
53+
async def test_transport_retry_succeeds_after_failures(clean_client_pool: None) -> None:
54+
"""Test that request retries and succeeds after transient failures."""
55+
configure(
56+
data_api_root_url="https://test.example.com",
57+
api_key="test-key",
58+
retry=tenacity.AsyncRetrying(
59+
stop=tenacity.stop_after_attempt(3),
60+
wait=tenacity.wait_none(),
61+
retry=tenacity.retry_if_exception_type(httpx.HTTPStatusError),
62+
),
63+
)
64+
65+
route = respx.get("https://test.example.com/test").mock(
66+
side_effect=[
67+
httpx.Response(500),
68+
httpx.Response(500),
69+
httpx.Response(200, json={"result": "ok"}),
70+
]
71+
)
72+
73+
async with Transport() as transport:
74+
response = await transport.get("/test")
75+
assert response.status_code == 200
76+
77+
assert route.call_count == 3
78+
79+
80+
@respx.mock
81+
async def test_transport_retry_exhausted(clean_client_pool: None) -> None:
82+
"""Test that RetryError is raised when all retry attempts fail."""
83+
configure(
84+
data_api_root_url="https://test.example.com",
85+
api_key="test-key",
86+
retry=tenacity.AsyncRetrying(
87+
stop=tenacity.stop_after_attempt(3),
88+
wait=tenacity.wait_none(),
89+
retry=tenacity.retry_if_exception_type(httpx.HTTPStatusError),
90+
),
91+
)
92+
93+
route = respx.get("https://test.example.com/test").mock(return_value=httpx.Response(500))
94+
95+
async with Transport() as transport:
96+
with pytest.raises(tenacity.RetryError):
97+
await transport.get("/test")
98+
99+
assert route.call_count == 3

0 commit comments

Comments
 (0)