Skip to content

Commit a294e22

Browse files
committed
Merge branch 'je-httpx-retry' into je-http2-client
2 parents 4e49722 + 0b976cb commit a294e22

File tree

5 files changed

+39
-79
lines changed

5 files changed

+39
-79
lines changed

firebase_admin/_http_client.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
connect=1, read=1, status=4, status_forcelist=[500, 503],
4444
raise_on_status=False, backoff_factor=0.5, **_ANY_METHOD)
4545

46-
DEFAULT_HTTPX_RETRY_CONFIG = HttpxRetry(status=4, status_forcelist=[500, 503], backoff_factor=0.5)
46+
DEFAULT_HTTPX_RETRY_CONFIG = HttpxRetry(
47+
max_retries=4, status_forcelist=[500, 503], backoff_factor=0.5)
4748

4849

4950
DEFAULT_TIMEOUT_SECONDS = 120
@@ -166,16 +167,6 @@ def parse_body(self, resp):
166167
return resp.json()
167168

168169

169-
# Auth Flow
170-
# TODO: Remove comments
171-
# The aim here is to be able to get auth credentials right before the request is sent.
172-
# This is similar to what is done in transport.requests.AuthorizedSession().
173-
# We can then pass this in at the client level.
174-
175-
# Notes:
176-
# - This implementations does not cover timeouts on requests sent to refresh credentials.
177-
# - Uses HTTP/1 and a blocking credential for refreshing.
178-
# - Network error retries for refreshing credentials.
179170
class GoogleAuthCredentialFlow(httpx.Auth):
180171
"""Google Auth Credential Auth Flow"""
181172
def __init__(self, credential: credentials.Credentials):

firebase_admin/_retry.py

Lines changed: 15 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -34,23 +34,18 @@
3434

3535
class HttpxRetry:
3636
"""HTTPX based retry config"""
37-
# TODO: Decide
38-
# urllib3.Retry ignores the status_forcelist when respecting Retry-After header
39-
# Only 413, 429 and 503 errors are retried with the Retry-After header.
40-
# Should we do the same?
41-
# Default status codes to be used for ``status_forcelist``
37+
# Status codes to be used for respecting `Retry-After` header
4238
RETRY_AFTER_STATUS_CODES = frozenset([413, 429, 503])
4339

44-
#: Default maximum backoff time.
40+
# Default maximum backoff time.
4541
DEFAULT_BACKOFF_MAX = 120
4642

4743
def __init__(
4844
self,
49-
status: int = 10,
45+
max_retries: int = 10,
5046
status_forcelist: Optional[List[int]] = None,
5147
backoff_factor: float = 0,
5248
backoff_max: float = DEFAULT_BACKOFF_MAX,
53-
raise_on_status: bool = False,
5449
backoff_jitter: float = 0,
5550
history: Optional[List[Tuple[
5651
httpx.Request,
@@ -59,11 +54,10 @@ def __init__(
5954
]]] = None,
6055
respect_retry_after_header: bool = False,
6156
) -> None:
62-
self.status = status
57+
self.retries_left = max_retries
6358
self.status_forcelist = status_forcelist
6459
self.backoff_factor = backoff_factor
6560
self.backoff_max = backoff_max
66-
self.raise_on_status = raise_on_status
6761
self.backoff_jitter = backoff_jitter
6862
if history:
6963
self.history = history
@@ -90,16 +84,10 @@ def is_retryable_response(self, response: httpx.Response) -> bool:
9084

9185
return False
9286

93-
# Placeholder for exception retrying
94-
def is_retryable_error(self, error: Exception):
95-
"""Determine if the error implies that the request should be retired if possible."""
96-
logger.debug(error)
97-
return False
98-
9987
def is_exhausted(self) -> bool:
10088
"""Determine if there are anymore more retires."""
101-
# status count is negative
102-
return self.status < 0
89+
# retries_left is negative
90+
return self.retries_left < 0
10391

10492
# Identical implementation of `urllib3.Retry.parse_retry_after()`
10593
def _parse_retry_after(self, retry_after_header: str) -> float | None:
@@ -111,7 +99,6 @@ def _parse_retry_after(self, retry_after_header: str) -> float | None:
11199
else:
112100
retry_date_tuple = email.utils.parsedate_tz(retry_after_header)
113101
if retry_date_tuple is None:
114-
# TODO: Verify if this is the appropriate way to handle this.
115102
raise httpx.RemoteProtocolError(f"Invalid Retry-After header: {retry_after_header}")
116103

117104
retry_date = email.utils.mktime_tz(retry_date_tuple)
@@ -167,53 +154,37 @@ def increment(
167154
error: Optional[Exception] = None
168155
) -> None:
169156
"""Update the retry state based on request attempt."""
170-
if response and self.is_retryable_response(response):
171-
self.status -= 1
157+
self.retries_left -= 1
172158
self.history.append((request, response, error))
173159

174160

175-
# TODO: Remove comments
176-
# Note - This implementation currently covers:
177-
# - basic retires for pre-defined status errors
178-
# - applying retry backoff and backoff jitter
179-
# - ability to respect a response's retry-after header
180161
class HttpxRetryTransport(httpx.AsyncBaseTransport):
181162
"""HTTPX transport with retry logic."""
182163

183-
# DEFAULT_RETRY = HttpxRetry(
184-
# connect=1, read=1, status=4, status_forcelist=[500, 503],
185-
# raise_on_status=False, backoff_factor=0.5, allowed_methods=None
186-
# )
187-
DEFAULT_RETRY = HttpxRetry(status=4, status_forcelist=[500, 503], backoff_factor=0.5)
164+
DEFAULT_RETRY = HttpxRetry(max_retries=4, status_forcelist=[500, 503], backoff_factor=0.5)
188165

189-
# We could also support passing kwargs here
190166
def __init__(self, retry: HttpxRetry = DEFAULT_RETRY, **kwargs) -> None:
191167
self._retry = retry
192168

193169
transport_kwargs = kwargs.copy()
194170
transport_kwargs.update({'retries': 0, 'http2': True})
195-
# We should use a full AsyncHTTPTransport under the hood since that is
196-
# fully implemented. We could consider making this class extend a
197-
# AsyncHTTPTransport instead and use the parent class's methods to handle
198-
# requests. We sould also ensure that that transport's internal retry is
199-
# not enabled.
171+
# We use a full AsyncHTTPTransport under the hood that is already
172+
# set up to handle requests. We also insure that that transport's internal
173+
# retries are not allowed.
200174
self._wrapped_transport = httpx.AsyncHTTPTransport(**transport_kwargs)
201175

202176
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
203177
return await self._dispatch_with_retry(
204178
request, self._wrapped_transport.handle_async_request)
205179

206-
# Two types of retries
207-
# - Status code (500s, redirect)
208-
# - Error code (read, connect, other)
209180
async def _dispatch_with_retry(
210181
self,
211182
request: httpx.Request,
212183
dispatch_method: Callable[[httpx.Request], CoroutineType[Any, Any, httpx.Response]]
213184
) -> httpx.Response:
214185
"""Sends a request with retry logic using a provided dispatch method."""
215186
# This request config is used across all requests that use this transport and therefore
216-
# needs to be copied to be used for just this request ans it's retries.
187+
# needs to be copied to be used for just this request and it's retries.
217188
retry = self._retry.copy()
218189
# First request
219190
response, error = None, None
@@ -238,16 +209,16 @@ async def _dispatch_with_retry(
238209
if response and not retry.is_retryable_response(response):
239210
return response
240211

241-
if error and not retry.is_retryable_error(error):
212+
if error:
242213
raise error
243214

244-
retry.increment(request, response)
215+
retry.increment(request, response, error)
245216

246217
if response:
247218
return response
248219
if error:
249220
raise error
250-
raise Exception('_dispatch_with_retry() ended with no response or exception')
221+
raise AssertionError('_dispatch_with_retry() ended with no response or exception')
251222

252223
async def aclose(self) -> None:
253224
await self._wrapped_transport.aclose()

firebase_admin/messaging.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -670,11 +670,6 @@ def _handle_batch_error(self, error):
670670
return _gapic_utils.handle_platform_error_from_googleapiclient(
671671
error, _MessagingService._build_fcm_error_googleapiclient)
672672

673-
# TODO: Remove comments
674-
# We should be careful to clean up the httpx clients.
675-
# Since we are using an async client we must also close in async. However we can sync wrap this.
676-
# The close method is called by the app on shutdown/clean-up of each service. We don't seem to
677-
# make use of this much elsewhere.
678673
def close(self) -> None:
679674
asyncio.run(self._async_client.aclose())
680675

tests/test_http_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ def test_init_with_custom_settings(self, mocker: MockerFixture):
276276

277277
mock_credential = testutils.MockGoogleCredential()
278278
headers = {'X-Custom': 'Test'}
279-
custom_retry = HttpxRetry(status=1, status_forcelist=[429], backoff_factor=0)
279+
custom_retry = HttpxRetry(max_retries=1, status_forcelist=[429], backoff_factor=0)
280280
timeout = 60
281281
http2 = False
282282

tests/test_retry.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class TestHttpxRetryTransport():
3737
@respx.mock
3838
async def test_no_retry_on_success(self, base_url: str, mocker: MockerFixture):
3939
"""Test that a successful response doesn't trigger retries."""
40-
retry_config = HttpxRetry(status=3, status_forcelist=[500])
40+
retry_config = HttpxRetry(max_retries=3, status_forcelist=[500])
4141
transport = HttpxRetryTransport(retry=retry_config)
4242
client = httpx.AsyncClient(transport=transport)
4343

@@ -55,7 +55,7 @@ async def test_no_retry_on_success(self, base_url: str, mocker: MockerFixture):
5555
@respx.mock
5656
async def test_no_retry_on_non_retryable_status(self, base_url: str, mocker: MockerFixture):
5757
"""Test that a non-retryable error status doesn't trigger retries."""
58-
retry_config = HttpxRetry(status=3, status_forcelist=[500, 503])
58+
retry_config = HttpxRetry(max_retries=3, status_forcelist=[500, 503])
5959
transport = HttpxRetryTransport(retry=retry_config)
6060
client = httpx.AsyncClient(transport=transport)
6161

@@ -75,7 +75,7 @@ async def test_retry_on_status_code_success_on_last_retry(
7575
self, base_url: str, mocker: MockerFixture
7676
):
7777
"""Test retry on status code from status_forcelist, succeeding on the last attempt."""
78-
retry_config = HttpxRetry(status=2, status_forcelist=[503, 500], backoff_factor=0.5)
78+
retry_config = HttpxRetry(max_retries=2, status_forcelist=[503, 500], backoff_factor=0.5)
7979
transport = HttpxRetryTransport(retry=retry_config)
8080
client = httpx.AsyncClient(transport=transport)
8181

@@ -101,7 +101,7 @@ async def test_retry_exhausted_returns_last_response(
101101
self, base_url: str, mocker: MockerFixture
102102
):
103103
"""Test that the last response is returned when retries are exhausted."""
104-
retry_config = HttpxRetry(status=1, status_forcelist=[500], backoff_factor=0)
104+
retry_config = HttpxRetry(max_retries=1, status_forcelist=[500], backoff_factor=0)
105105
transport = HttpxRetryTransport(retry=retry_config)
106106
client = httpx.AsyncClient(transport=transport)
107107

@@ -124,7 +124,8 @@ async def test_retry_exhausted_returns_last_response(
124124
@respx.mock
125125
async def test_retry_after_header_seconds(self, base_url: str, mocker: MockerFixture):
126126
"""Test respecting Retry-After header with seconds value."""
127-
retry_config = HttpxRetry(status=1, respect_retry_after_header=True, backoff_factor=100)
127+
retry_config = HttpxRetry(
128+
max_retries=1, respect_retry_after_header=True, backoff_factor=100)
128129
transport = HttpxRetryTransport(retry=retry_config)
129130
client = httpx.AsyncClient(transport=transport)
130131

@@ -146,7 +147,8 @@ async def test_retry_after_header_seconds(self, base_url: str, mocker: MockerFix
146147
@respx.mock
147148
async def test_retry_after_header_http_date(self, base_url: str, mocker: MockerFixture):
148149
"""Test respecting Retry-After header with an HTTP-date value."""
149-
retry_config = HttpxRetry(status=1, respect_retry_after_header=True, backoff_factor=100)
150+
retry_config = HttpxRetry(
151+
max_retries=1, respect_retry_after_header=True, backoff_factor=100)
150152
transport = HttpxRetryTransport(retry=retry_config)
151153
client = httpx.AsyncClient(transport=transport)
152154

@@ -181,7 +183,7 @@ async def test_retry_after_header_http_date(self, base_url: str, mocker: MockerF
181183
async def test_retry_after_ignored_when_disabled(self, base_url: str, mocker: MockerFixture):
182184
"""Test Retry-After header is ignored if `respect_retry_after_header` is `False`."""
183185
retry_config = HttpxRetry(
184-
status=3, respect_retry_after_header=False, status_forcelist=[429],
186+
max_retries=3, respect_retry_after_header=False, status_forcelist=[429],
185187
backoff_factor=0.5, backoff_max=10)
186188
transport = HttpxRetryTransport(retry=retry_config)
187189
client = httpx.AsyncClient(transport=transport)
@@ -215,7 +217,7 @@ async def test_retry_after_header_missing_backoff_fallback(
215217
"""Test Retry-After header is ignored if `respect_retry_after_header`is `True` but header is
216218
not set."""
217219
retry_config = HttpxRetry(
218-
status=3, respect_retry_after_header=True, status_forcelist=[429],
220+
max_retries=3, respect_retry_after_header=True, status_forcelist=[429],
219221
backoff_factor=0.5, backoff_max=10)
220222
transport = HttpxRetryTransport(retry=retry_config)
221223
client = httpx.AsyncClient(transport=transport)
@@ -247,7 +249,7 @@ async def test_exponential_backoff(self, base_url: str, mocker: MockerFixture):
247249
"""Test that sleep time increases exponentially with `backoff_factor`."""
248250
# status=3 allows 3 retries (attempts 2, 3, 4)
249251
retry_config = HttpxRetry(
250-
status=3, status_forcelist=[500], backoff_factor=0.1, backoff_max=10.0)
252+
max_retries=3, status_forcelist=[500], backoff_factor=0.1, backoff_max=10.0)
251253
transport = HttpxRetryTransport(retry=retry_config)
252254
client = httpx.AsyncClient(transport=transport)
253255

@@ -278,7 +280,7 @@ async def test_backoff_max(self, base_url: str, mocker: MockerFixture):
278280
"""Test that backoff time respects `backoff_max`."""
279281
# status=4 allows 4 retries. backoff_factor=1 causes rapid increase.
280282
retry_config = HttpxRetry(
281-
status=4, status_forcelist=[500], backoff_factor=1, backoff_max=3.0)
283+
max_retries=4, status_forcelist=[500], backoff_factor=1, backoff_max=3.0)
282284
transport = HttpxRetryTransport(retry=retry_config)
283285
client = httpx.AsyncClient(transport=transport)
284286

@@ -310,7 +312,7 @@ async def test_backoff_max(self, base_url: str, mocker: MockerFixture):
310312
async def test_backoff_jitter(self, base_url: str, mocker: MockerFixture):
311313
"""Test that `backoff_jitter` adds randomness within bounds."""
312314
retry_config = HttpxRetry(
313-
status=3, status_forcelist=[500], backoff_factor=0.2, backoff_jitter=0.1)
315+
max_retries=3, status_forcelist=[500], backoff_factor=0.2, backoff_jitter=0.1)
314316
transport = HttpxRetryTransport(retry=retry_config)
315317
client = httpx.AsyncClient(transport=transport)
316318

@@ -343,7 +345,7 @@ async def test_backoff_jitter(self, base_url: str, mocker: MockerFixture):
343345
@respx.mock
344346
async def test_error_not_retryable(self, base_url):
345347
"""Test that non-HTTP errors are raised immediately if not retryable."""
346-
retry_config = HttpxRetry(status=3)
348+
retry_config = HttpxRetry(max_retries=3)
347349
transport = HttpxRetryTransport(retry=retry_config)
348350
client = httpx.AsyncClient(transport=transport)
349351

@@ -362,7 +364,7 @@ class TestHttpxRetry():
362364

363365
def test_httpx_retry_copy(self, base_url):
364366
"""Test that `HttpxRetry.copy()` creates a deep copy."""
365-
original = HttpxRetry(status=5, status_forcelist=[500, 503], backoff_factor=0.5)
367+
original = HttpxRetry(max_retries=5, status_forcelist=[500, 503], backoff_factor=0.5)
366368
original.history.append((base_url, None, None)) # Add something mutable
367369

368370
copied = original.copy()
@@ -372,17 +374,17 @@ def test_httpx_retry_copy(self, base_url):
372374
assert original.history is not copied.history
373375

374376
# Assert values are the same initially
375-
assert copied.status == original.status
377+
assert copied.retries_left == original.retries_left
376378
assert copied.status_forcelist == original.status_forcelist
377379
assert copied.backoff_factor == original.backoff_factor
378380
assert len(copied.history) == 1
379381

380382
# Modify the copy and check original is unchanged
381-
copied.status = 1
383+
copied.retries_left = 1
382384
copied.status_forcelist = [404]
383385
copied.history.append((base_url, None, None))
384386

385-
assert original.status == 5
387+
assert original.retries_left == 5
386388
assert original.status_forcelist == [500, 503]
387389
assert len(original.history) == 1
388390

@@ -413,7 +415,8 @@ def test_parse_retry_after_invalid_date(self):
413415
retry._parse_retry_after('Invalid Date Format')
414416

415417
def test_get_backoff_time_calculation(self):
416-
retry = HttpxRetry(status=6, status_forcelist=[503], backoff_factor=0.5, backoff_max=10.0)
418+
retry = HttpxRetry(
419+
max_retries=6, status_forcelist=[503], backoff_factor=0.5, backoff_max=10.0)
417420
response = httpx.Response(503)
418421
# No history -> attempt 1 -> no backoff before first request
419422
# Note: get_backoff_time() is typically called *before* the *next* request,
@@ -447,5 +450,5 @@ def test_get_backoff_time_calculation(self):
447450

448451
# Simulate attempt 6 completed
449452
retry.increment(self._TEST_REQUEST, response)
450-
# History len 6, attempt 7 -> 0.5*(2^4) = 10.0
453+
# History len 6, attempt 7 -> 0.5*(2^5) = 16.0 Clamped to 10
451454
assert retry.get_backoff_time() == pytest.approx(10.0)

0 commit comments

Comments
 (0)