Skip to content

Commit 019c28b

Browse files
authored
Merge pull request #9 from austind/develop
Implement support for HTTP-dates in `wait_from_header()`
2 parents bf81679 + a6e7ca5 commit 019c28b

File tree

8 files changed

+112
-45
lines changed

8 files changed

+112
-45
lines changed

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ Supports exceptions raised by both [`requests`](https://docs.python-requests.org
2323

2424
## Install
2525

26-
## Install
27-
2826
Install from PyPI:
2927

3028
```sh

docs/changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## v1.1.0
4+
5+
* Add [HTTP-date](https://httpwg.org/specs/rfc9110.html#http.date) value parsing for [`retryhttp.wait_from_header`][]
6+
* [`is_rate_limited`][`retryhttp._utils.is_rate_limited`] now determines that a request was rate limited by the presence of a `Retry-After` header. Prior to v1.1.0, this was based on the status code `429 Too Many Requests`.
7+
38
## v1.0.1
49

510
* Fix documentation errors.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ classifiers = [
3838
]
3939
dependencies = [
4040
"httpx",
41+
"pydantic",
4142
"requests",
4243
"tenacity"
4344
]

retryhttp/_retry.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def retry(
4646
"""Retry potentially transient HTTP errors with sensible default behavior.
4747
4848
By default, retries the following errors, for a total of 3 attempts, with
49-
exponential backoff (except for `429 Too Many Requests`, which defaults to the
49+
exponential backoff (except when rate limited, which defaults to the
5050
`Retry-After` header, if present):
5151
5252
- HTTP status errors:
@@ -71,11 +71,11 @@ def retry(
7171
retry_server_errors: Whether to retry 5xx server errors.
7272
retry_network_errors: Whether to retry network errors.
7373
retry_timeouts: Whether to retry timeouts.
74-
retry_rate_limited: Whether to retry `429 Too Many Requests` errors.
74+
retry_rate_limited: Whether to retry when `Retry-After` header received.
7575
wait_server_errors: Wait strategy to use for server errors.
7676
wait_network_errors: Wait strategy to use for network errors.
7777
wait_timeouts: Wait strategy to use for timeouts.
78-
wait_rate_limited: Wait strategy to use for `429 Too Many Requests` errors.
78+
wait_rate_limited: Wait strategy to use when `Retry-After` header received.
7979
server_error_codes: One or more 5xx error codes that will trigger `wait_server_errors`
8080
if `retry_server_errors` is `True`. Defaults to 500, 502, 503, and 504.
8181
network_errors: One or more exceptions that will trigger `wait_network_errors` if
@@ -85,6 +85,7 @@ def retry(
8585
- `httpx.ReadError`
8686
- `httpx.WriteError`
8787
- `requests.ConnectError`
88+
- `requests.exceptions.ChunkedEncodingError`
8889
timeouts: One or more exceptions that will trigger `wait_timeouts` if
8990
`retry_timeouts` is `True`. Defaults to:
9091
@@ -95,7 +96,7 @@ def retry(
9596
Decorated function.
9697
9798
Raises:
98-
RuntimeError: if `retry_server_errors`, `retry_network_errors`, `retry_timeouts`,
99+
RuntimeError: If `retry_server_errors`, `retry_network_errors`, `retry_timeouts`,
99100
and `retry_rate_limited` are all `False`.
100101
101102
"""
@@ -167,7 +168,7 @@ def __init__(
167168

168169

169170
class retry_if_rate_limited(retry_base):
170-
"""Retry if server responds with `429 Too Many Requests` (rate limited)."""
171+
"""Retry if server responds with a `Retry-After` header."""
171172

172173
def __call__(self, retry_state: RetryCallState) -> bool:
173174
if retry_state.outcome and retry_state.outcome.failed:

retryhttp/_types.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
1+
from datetime import datetime
12
from typing import Any, Callable, TypeVar
23

4+
5+
class HTTPDate(str):
6+
@classmethod
7+
def __get_validators__(cls):
8+
yield cls.validate
9+
10+
@classmethod
11+
def validate(cls, value: str) -> str:
12+
try:
13+
datetime.strptime(value, "%a, %d %b %Y %H:%M:%S GMT")
14+
except ValueError:
15+
raise ValueError(f"Invalid HTTP-date format: {value}")
16+
return value
17+
18+
319
F = TypeVar("F", bound=Callable[..., Any])
420
WrappedFn = TypeVar("WrappedFn", bound=Callable[..., Any])

retryhttp/_utils.py

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
from datetime import datetime, timedelta, timezone
12
from typing import Optional, Sequence, Tuple, Type, Union
23

3-
import httpx
4-
import requests
4+
from retryhttp._types import HTTPDate
55

66
_HTTPX_INSTALLED = False
77
_REQUESTS_INSTALLED = False
@@ -60,14 +60,8 @@ def get_default_timeouts() -> (
6060
):
6161
"""Get all timeout exceptions to use by default.
6262
63-
Args:
64-
N/A
65-
6663
Returns:
67-
Tuple of timeout exceptions.
68-
69-
Raises:
70-
N/A
64+
tuple: Timeout exceptions.
7165
7266
"""
7367
exceptions = []
@@ -83,14 +77,8 @@ def get_default_http_status_exceptions() -> (
8377
):
8478
"""Get default HTTP status 4xx or 5xx exceptions.
8579
86-
Args:
87-
N/A
88-
8980
Returns:
90-
Tuple of HTTP status exceptions.
91-
92-
Raises:
93-
N/A
81+
tuple: HTTP status exceptions.
9482
9583
"""
9684
exceptions = []
@@ -102,20 +90,22 @@ def get_default_http_status_exceptions() -> (
10290

10391

10492
def is_rate_limited(exc: Union[BaseException, None]) -> bool:
105-
"""Whether a given exception indicates a 429 Too Many Requests error.
93+
"""Whether a given exception indicates the user has been rate limited.
94+
95+
Rate limiting should return a `429 Too Many Requests` status, but in
96+
practice, servers may return `503 Service Unavailable`, or possibly
97+
another code. In any case, if rate limiting is the issue, the server
98+
will include a `Retry-After` header.
10699
107100
Args:
108101
exc: Exception to consider.
109102
110103
Returns:
111-
Boolean of whether exc indicates a 429 Too Many Requests error.
112-
113-
Raises:
114-
N/A
104+
bool: Whether exc indicates rate limiting.
115105
116106
"""
117107
if isinstance(exc, get_default_http_status_exceptions()):
118-
return exc.response.status_code == 429
108+
return "retry-after" in exc.response.headers.keys()
119109
return False
120110

121111

@@ -131,14 +121,31 @@ def is_server_error(
131121
to all (500-599).
132122
133123
Returns:
134-
Boolean of whether exc indicates an error included in status_codes.
135-
136-
Raises:
137-
N/A
124+
bool: whether exc indicates an error included in status_codes.
138125
139126
"""
140127
if isinstance(status_codes, int):
141128
status_codes = [status_codes]
142129
if isinstance(exc, get_default_http_status_exceptions()):
143130
return exc.response.status_code in status_codes
144131
return False
132+
133+
134+
def get_http_date(delta_seconds: int = 0) -> HTTPDate:
135+
"""Builds a valid HTTP-date string.
136+
137+
By default, returns an HTTP-date string for the current timestamp.
138+
139+
Args:
140+
delta_seconds (int): Number of seconds to offset the timestamp
141+
by. If a negative integer is passed, result will be in the
142+
past.
143+
144+
Returns:
145+
HTTPDate: A valid HTTP-date string.
146+
147+
"""
148+
date = datetime.now(timezone.utc)
149+
if delta_seconds:
150+
date = date + timedelta(seconds=delta_seconds)
151+
return HTTPDate(date.strftime("%a, %d %b %Y %H:%M:%S GMT"))

retryhttp/_wait.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from datetime import datetime, timezone
12
from typing import Sequence, Tuple, Type, Union
23

34
from tenacity import RetryCallState, wait_exponential, wait_random_exponential
@@ -15,6 +16,13 @@
1516
class wait_from_header(wait_base):
1617
"""Wait strategy that derives the wait value from an HTTP header.
1718
19+
Value may be either an integer representing the number of seconds to wait
20+
before retrying, or a date in HTTP-date format, indicating when it is
21+
acceptable to retry the request. If such a date value is found, this method
22+
will use that value to determine the correct number of seconds to wait.
23+
24+
More info on HTTP-date format: https://httpwg.org/specs/rfc9110.html#http.date
25+
1826
Args:
1927
header: Header to attempt to derive wait value from.
2028
fallback: Wait strategy to use if `header` is not present, or unable
@@ -34,21 +42,31 @@ def __call__(self, retry_state: RetryCallState) -> float:
3442
if retry_state.outcome:
3543
exc = retry_state.outcome.exception()
3644
if isinstance(exc, get_default_http_status_exceptions()):
45+
value = exc.response.headers.get(self.header, "")
46+
3747
try:
38-
return float(
39-
exc.response.headers.get(
40-
self.header, self.fallback(retry_state)
41-
)
42-
)
48+
return float(value)
4349
except ValueError:
4450
pass
51+
52+
try:
53+
retry_after = datetime.strptime(value, "%a, %d %b %Y %H:%M:%S GMT")
54+
retry_after = retry_after.replace(tzinfo=timezone.utc)
55+
now = datetime.now(timezone.utc)
56+
return float((retry_after - now).seconds)
57+
except ValueError:
58+
pass
59+
4560
return self.fallback(retry_state)
4661

4762

4863
class wait_rate_limited(wait_from_header):
49-
"""Wait strategy to use when the server responds with `429 Too Many Requests`.
64+
"""Wait strategy to use when the server responds with a `Retry-After` header.
5065
51-
Attempts to derive wait value from the `Retry-After` header.
66+
The `Retry-After` header may be sent with the `503 Service Unavailable` or
67+
`429 Too Many Requests` status code. The header value may provide a date for when
68+
you may retry the request, or an integer, indicating the number of seconds
69+
to wait before retrying.
5270
5371
Args:
5472
fallback: Wait strategy to use if `Retry-After` header is not present, or unable
@@ -70,7 +88,7 @@ class wait_context_aware(wait_base):
7088
wait_server_errors: Wait strategy to use with server errors.
7189
wait_network_errors: Wait strategy to use with network errors.
7290
wait_timeouts: Wait strategy to use with timeouts.
73-
wait_rate_limited: Wait strategy to use with `429 Too Many Requests`.
91+
wait_rate_limited: Wait strategy to use when rate limited.
7492
server_error_codes: One or more 5xx HTTP status codes that will trigger
7593
`wait_server_errors`.
7694
network_errors: One or more exceptions that will trigger `wait_network_errors`.
@@ -88,6 +106,7 @@ class wait_context_aware(wait_base):
88106
- `httpx.ReadTimeout`
89107
- `httpx.WriteTimeout`
90108
- `requests.Timeout`
109+
91110
"""
92111

93112
def __init__(

tests/test_rate_limited.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1+
from typing import Union
2+
13
import httpx
24
import pytest
35
import respx
6+
from pydantic import PositiveInt
47
from tenacity import RetryError, retry, stop_after_attempt
58

69
from retryhttp import retry_if_rate_limited, wait_rate_limited
10+
from retryhttp._types import HTTPDate
11+
from retryhttp._utils import get_http_date
712

813
MOCK_URL = "https://example.com/"
914

1015

11-
def rate_limited_response(retry_after: int = 1):
16+
def rate_limited_response(retry_after: Union[HTTPDate, PositiveInt] = 1):
1217
return httpx.Response(
1318
status_code=httpx.codes.TOO_MANY_REQUESTS,
1419
headers={"Retry-After": str(retry_after)},
@@ -30,9 +35,24 @@ def retry_rate_limited():
3035
def test_rate_limited_failure():
3136
route = respx.get(MOCK_URL).mock(
3237
side_effect=[
33-
rate_limited_response(),
34-
rate_limited_response(),
35-
rate_limited_response(),
38+
rate_limited_response(retry_after=1),
39+
rate_limited_response(retry_after=1),
40+
rate_limited_response(retry_after=1),
41+
]
42+
)
43+
with pytest.raises(RetryError):
44+
retry_rate_limited()
45+
assert route.call_count == 3
46+
assert route.calls[2].response.status_code == 429
47+
48+
49+
@respx.mock
50+
def test_rate_limited_failure_httpdate():
51+
route = respx.get(MOCK_URL).mock(
52+
side_effect=[
53+
rate_limited_response(retry_after=get_http_date(delta_seconds=2)),
54+
rate_limited_response(retry_after=get_http_date(delta_seconds=2)),
55+
rate_limited_response(retry_after=get_http_date(delta_seconds=2)),
3656
]
3757
)
3858
with pytest.raises(RetryError):

0 commit comments

Comments
 (0)