httpx-limiter 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {httpx_limiter-0.2.0 → httpx_limiter-0.3.0}/PKG-INFO +77 -7
- {httpx_limiter-0.2.0 → httpx_limiter-0.3.0}/README.md +76 -6
- {httpx_limiter-0.2.0 → httpx_limiter-0.3.0}/pyproject.toml +1 -8
- {httpx_limiter-0.2.0 → httpx_limiter-0.3.0}/src/httpx_limiter/__init__.py +2 -0
- {httpx_limiter-0.2.0 → httpx_limiter-0.3.0}/src/httpx_limiter/_version.py +2 -2
- httpx_limiter-0.3.0/src/httpx_limiter/abstract_rate_limiter_repository.py +53 -0
- httpx_limiter-0.3.0/src/httpx_limiter/async_multi_rate_limited_transport.py +84 -0
- {httpx_limiter-0.2.0 → httpx_limiter-0.3.0}/src/httpx_limiter/async_rate_limited_transport.py +1 -1
- {httpx_limiter-0.2.0 → httpx_limiter-0.3.0}/.gitignore +0 -0
- {httpx_limiter-0.2.0 → httpx_limiter-0.3.0}/LICENSE +0 -0
- {httpx_limiter-0.2.0 → httpx_limiter-0.3.0}/src/httpx_limiter/py.typed +0 -0
- {httpx_limiter-0.2.0 → httpx_limiter-0.3.0}/src/httpx_limiter/rate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpx-limiter
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A lightweight package that provides rate-limited httpx transports.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Midnighter/httpx-limiter
|
|
6
6
|
Project-URL: Documentation, https://httpx-limiter.readthedocs.io
|
|
@@ -49,12 +49,16 @@ Install it, for example, with
|
|
|
49
49
|
pip install httpx-limiter
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
##
|
|
52
|
+
## Tutorial
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
You can limit the number of requests made by an HTTPX client using the transports
|
|
55
|
+
provided in this package. That is useful in situations when you need to make a large
|
|
56
|
+
number of asynchronous requests against endpoints that implement a rate limit,
|
|
57
|
+
|
|
58
|
+
### Single Rate Limit
|
|
59
|
+
|
|
60
|
+
The simplest use case is to apply a single rate limit to all requests. If you want to be
|
|
61
|
+
able to make twenty requests per second, for example, use the following code:
|
|
58
62
|
|
|
59
63
|
```python
|
|
60
64
|
import httpx
|
|
@@ -62,11 +66,77 @@ from httpx_limiter import AsyncRateLimitedTransport, Rate
|
|
|
62
66
|
|
|
63
67
|
async def main():
|
|
64
68
|
async with httpx.AsyncClient(
|
|
65
|
-
transport=AsyncRateLimitedTransport.create(rate=Rate.create(magnitude=
|
|
69
|
+
transport=AsyncRateLimitedTransport.create(rate=Rate.create(magnitude=20)),
|
|
66
70
|
) as client:
|
|
67
71
|
response = await client.get("https://httpbin.org")
|
|
68
72
|
```
|
|
69
73
|
|
|
74
|
+
> [!IMPORTANT]
|
|
75
|
+
> Due to limitations in the design of the underlying leaky bucket implementation,
|
|
76
|
+
> which is used to implement the rate limiting, the magnitude of the rate is also the
|
|
77
|
+
> maximum capacity of the bucket. That means, if you set a rate that is larger than one,
|
|
78
|
+
> a burst of requests equal to that capacity will be allowed. If you do not want to
|
|
79
|
+
> allow any bursts, set the magnitude to one, but the duration to the inverse of your
|
|
80
|
+
> desired rate. If you want to allow twenty requests per second, for example, set the
|
|
81
|
+
> magnitude to 1 and the duration to 0.05 seconds.
|
|
82
|
+
|
|
83
|
+
### Multiple Rate Limits
|
|
84
|
+
|
|
85
|
+
For more advanced use cases, you can apply different rate limits based on a concrete
|
|
86
|
+
implementation of the `AbstractRateLimiterRepository`. There are two relevant methods
|
|
87
|
+
that both get passed the current request. One method needs to identify which rate limit
|
|
88
|
+
to apply, and the other method sets the rate limit itself. See the following example:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import httpx
|
|
92
|
+
from httpx_limiter import AbstractRateLimiterRepository, AsyncMultiRateLimitedTransport, Rate
|
|
93
|
+
|
|
94
|
+
class DomainBasedRateLimiterRepository(AbstractRateLimiterRepository):
|
|
95
|
+
"""Apply different rate limits based on the domain being requested."""
|
|
96
|
+
|
|
97
|
+
def get_identifier(self, request: httpx.Request) -> str:
|
|
98
|
+
"""Return the domain as the identifier for rate limiting."""
|
|
99
|
+
return request.url.host
|
|
100
|
+
|
|
101
|
+
def get_rate(self, request: httpx.Request) -> Rate:
|
|
102
|
+
"""Apply the same, but independent rate limit to each domain."""
|
|
103
|
+
return Rate.create(magnitude=25)
|
|
104
|
+
|
|
105
|
+
client = httpx.AsyncClient(
|
|
106
|
+
transport=AsyncMultiRateLimitedTransport.create(
|
|
107
|
+
repository=DomainBasedRateLimiterRepository(),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
> [!NOTE]
|
|
113
|
+
> You are free to ignore the request argument and use global information like the time
|
|
114
|
+
> of day to determine the rate limit.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from datetime import datetime, timezone
|
|
118
|
+
|
|
119
|
+
import httpx
|
|
120
|
+
from httpx_limiter import AbstractRateLimiterRepository, AsyncMultiRateLimitedTransport, Rate
|
|
121
|
+
|
|
122
|
+
class DayNightRateLimiterRepository(AbstractRateLimiterRepository):
|
|
123
|
+
"""Apply different rate limits based on the time of day."""
|
|
124
|
+
|
|
125
|
+
def get_identifier(self, _: httpx.Request) -> str:
|
|
126
|
+
"""Identify whether it is currently day or night."""
|
|
127
|
+
if 6 <= datetime.now(tz=timezone.utc).hour < 18:
|
|
128
|
+
return "day"
|
|
129
|
+
|
|
130
|
+
return "night"
|
|
131
|
+
|
|
132
|
+
def get_rate(self, _: httpx.Request) -> Rate:
|
|
133
|
+
"""Apply different rate limits during the day or night."""
|
|
134
|
+
if self.get_identifier(_) == "day":
|
|
135
|
+
return Rate.create(magnitude=10)
|
|
136
|
+
|
|
137
|
+
return Rate.create(magnitude=100)
|
|
138
|
+
```
|
|
139
|
+
|
|
70
140
|
## Copyright
|
|
71
141
|
|
|
72
142
|
- Copyright © 2024,2025 Moritz E. Beber.
|
|
@@ -17,12 +17,16 @@ Install it, for example, with
|
|
|
17
17
|
pip install httpx-limiter
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
##
|
|
20
|
+
## Tutorial
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
You can limit the number of requests made by an HTTPX client using the transports
|
|
23
|
+
provided in this package. That is useful in situations when you need to make a large
|
|
24
|
+
number of asynchronous requests against endpoints that implement a rate limit,
|
|
25
|
+
|
|
26
|
+
### Single Rate Limit
|
|
27
|
+
|
|
28
|
+
The simplest use case is to apply a single rate limit to all requests. If you want to be
|
|
29
|
+
able to make twenty requests per second, for example, use the following code:
|
|
26
30
|
|
|
27
31
|
```python
|
|
28
32
|
import httpx
|
|
@@ -30,11 +34,77 @@ from httpx_limiter import AsyncRateLimitedTransport, Rate
|
|
|
30
34
|
|
|
31
35
|
async def main():
|
|
32
36
|
async with httpx.AsyncClient(
|
|
33
|
-
transport=AsyncRateLimitedTransport.create(rate=Rate.create(magnitude=
|
|
37
|
+
transport=AsyncRateLimitedTransport.create(rate=Rate.create(magnitude=20)),
|
|
34
38
|
) as client:
|
|
35
39
|
response = await client.get("https://httpbin.org")
|
|
36
40
|
```
|
|
37
41
|
|
|
42
|
+
> [!IMPORTANT]
|
|
43
|
+
> Due to limitations in the design of the underlying leaky bucket implementation,
|
|
44
|
+
> which is used to implement the rate limiting, the magnitude of the rate is also the
|
|
45
|
+
> maximum capacity of the bucket. That means, if you set a rate that is larger than one,
|
|
46
|
+
> a burst of requests equal to that capacity will be allowed. If you do not want to
|
|
47
|
+
> allow any bursts, set the magnitude to one, but the duration to the inverse of your
|
|
48
|
+
> desired rate. If you want to allow twenty requests per second, for example, set the
|
|
49
|
+
> magnitude to 1 and the duration to 0.05 seconds.
|
|
50
|
+
|
|
51
|
+
### Multiple Rate Limits
|
|
52
|
+
|
|
53
|
+
For more advanced use cases, you can apply different rate limits based on a concrete
|
|
54
|
+
implementation of the `AbstractRateLimiterRepository`. There are two relevant methods
|
|
55
|
+
that both get passed the current request. One method needs to identify which rate limit
|
|
56
|
+
to apply, and the other method sets the rate limit itself. See the following example:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import httpx
|
|
60
|
+
from httpx_limiter import AbstractRateLimiterRepository, AsyncMultiRateLimitedTransport, Rate
|
|
61
|
+
|
|
62
|
+
class DomainBasedRateLimiterRepository(AbstractRateLimiterRepository):
|
|
63
|
+
"""Apply different rate limits based on the domain being requested."""
|
|
64
|
+
|
|
65
|
+
def get_identifier(self, request: httpx.Request) -> str:
|
|
66
|
+
"""Return the domain as the identifier for rate limiting."""
|
|
67
|
+
return request.url.host
|
|
68
|
+
|
|
69
|
+
def get_rate(self, request: httpx.Request) -> Rate:
|
|
70
|
+
"""Apply the same, but independent rate limit to each domain."""
|
|
71
|
+
return Rate.create(magnitude=25)
|
|
72
|
+
|
|
73
|
+
client = httpx.AsyncClient(
|
|
74
|
+
transport=AsyncMultiRateLimitedTransport.create(
|
|
75
|
+
repository=DomainBasedRateLimiterRepository(),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
> [!NOTE]
|
|
81
|
+
> You are free to ignore the request argument and use global information like the time
|
|
82
|
+
> of day to determine the rate limit.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from datetime import datetime, timezone
|
|
86
|
+
|
|
87
|
+
import httpx
|
|
88
|
+
from httpx_limiter import AbstractRateLimiterRepository, AsyncMultiRateLimitedTransport, Rate
|
|
89
|
+
|
|
90
|
+
class DayNightRateLimiterRepository(AbstractRateLimiterRepository):
|
|
91
|
+
"""Apply different rate limits based on the time of day."""
|
|
92
|
+
|
|
93
|
+
def get_identifier(self, _: httpx.Request) -> str:
|
|
94
|
+
"""Identify whether it is currently day or night."""
|
|
95
|
+
if 6 <= datetime.now(tz=timezone.utc).hour < 18:
|
|
96
|
+
return "day"
|
|
97
|
+
|
|
98
|
+
return "night"
|
|
99
|
+
|
|
100
|
+
def get_rate(self, _: httpx.Request) -> Rate:
|
|
101
|
+
"""Apply different rate limits during the day or night."""
|
|
102
|
+
if self.get_identifier(_) == "day":
|
|
103
|
+
return Rate.create(magnitude=10)
|
|
104
|
+
|
|
105
|
+
return Rate.create(magnitude=100)
|
|
106
|
+
```
|
|
107
|
+
|
|
38
108
|
## Copyright
|
|
39
109
|
|
|
40
110
|
- Copyright © 2024,2025 Moritz E. Beber.
|
|
@@ -161,12 +161,7 @@ ignore_missing_imports = true
|
|
|
161
161
|
installer = "uv"
|
|
162
162
|
|
|
163
163
|
[tool.hatch.envs.dev]
|
|
164
|
-
description = """
|
|
165
|
-
Development environment.
|
|
166
|
-
|
|
167
|
-
Defining a path is a workaround for PyCharm,
|
|
168
|
-
see https://youtrack.jetbrains.com/issue/PY-60410.
|
|
169
|
-
"""
|
|
164
|
+
description = """Development environment."""
|
|
170
165
|
extra-dependencies = [
|
|
171
166
|
"anyio",
|
|
172
167
|
"pytest",
|
|
@@ -174,7 +169,6 @@ extra-dependencies = [
|
|
|
174
169
|
"pytest-watcher",
|
|
175
170
|
"ruff",
|
|
176
171
|
]
|
|
177
|
-
path = ".venv"
|
|
178
172
|
|
|
179
173
|
[tool.hatch.envs.style]
|
|
180
174
|
description = """Check the style of the codebase."""
|
|
@@ -256,7 +250,6 @@ run = "pytest {args:--cov=httpx_limiter --cov-report=term-missing} {root}/tests/
|
|
|
256
250
|
description = """Run the acceptance tests."""
|
|
257
251
|
extra-dependencies = [
|
|
258
252
|
"anyio",
|
|
259
|
-
# TODO: Use behave in future.
|
|
260
253
|
"pytest",
|
|
261
254
|
"pytest-cov",
|
|
262
255
|
"pytest-httpx",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Copyright (c) 2025 Moritz E. Beber
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
|
|
4
|
+
# use this file except in compliance with the License. You may obtain a copy of
|
|
5
|
+
# the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
11
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
12
|
+
# License for the specific language governing permissions and limitations under
|
|
13
|
+
# the License.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
"""Provide an abstract repository for rate limiters."""
|
|
17
|
+
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
from aiolimiter import AsyncLimiter
|
|
22
|
+
|
|
23
|
+
from .rate import Rate
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AbstractRateLimiterRepository(ABC):
|
|
27
|
+
"""Define the abstract repository for rate limiters."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, **kwargs) -> None:
|
|
30
|
+
super().__init__(**kwargs)
|
|
31
|
+
self._limiters: dict[str, AsyncLimiter] = {}
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def get_identifier(self, request: httpx.Request) -> str:
|
|
35
|
+
"""Return a request-specific identifier."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def get_rate(self, request: httpx.Request) -> Rate:
|
|
39
|
+
"""Return a request-specific rate."""
|
|
40
|
+
|
|
41
|
+
def get(self, request: httpx.Request) -> AsyncLimiter:
|
|
42
|
+
"""Return a request-specific rate limiter."""
|
|
43
|
+
identifier = self.get_identifier(request)
|
|
44
|
+
|
|
45
|
+
if identifier not in self._limiters:
|
|
46
|
+
rate = self.get_rate(request)
|
|
47
|
+
self._limiters[identifier] = AsyncLimiter(
|
|
48
|
+
max_rate=rate.magnitude,
|
|
49
|
+
time_period=rate.in_seconds(),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return self._limiters[identifier]
|
|
53
|
+
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Copyright (c) 2025 Moritz E. Beber
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
|
|
4
|
+
# use this file except in compliance with the License. You may obtain a copy of
|
|
5
|
+
# the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
11
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
12
|
+
# License for the specific language governing permissions and limitations under
|
|
13
|
+
# the License.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
"""Provide an asynchronous multiple rate-limited transport."""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
26
|
+
from .abstract_rate_limiter_repository import AbstractRateLimiterRepository
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncMultiRateLimitedTransport(httpx.AsyncBaseTransport):
|
|
30
|
+
"""
|
|
31
|
+
Define the asynchronous multiple rate-limited transport.
|
|
32
|
+
|
|
33
|
+
This transport consists of a composed transport for handling requests and a
|
|
34
|
+
repository for rate limiters that are selected based on the request.
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
repository: AbstractRateLimiterRepository,
|
|
42
|
+
transport: httpx.AsyncBaseTransport,
|
|
43
|
+
**kwargs,
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(**kwargs)
|
|
46
|
+
self._repo = repository
|
|
47
|
+
self._transport = transport
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def create(
|
|
51
|
+
cls,
|
|
52
|
+
*,
|
|
53
|
+
repository: AbstractRateLimiterRepository,
|
|
54
|
+
**kwargs: dict,
|
|
55
|
+
) -> AsyncMultiRateLimitedTransport:
|
|
56
|
+
"""
|
|
57
|
+
Create an instance of an asynchronous multiple rate-limited transport.
|
|
58
|
+
|
|
59
|
+
This factory method constructs the instance with an underlying
|
|
60
|
+
`httpx.AsyncHTTPTransport`.
|
|
61
|
+
That transport is passed any additional keyword arguments.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
repository: The repository to use for rate limiters.
|
|
65
|
+
**kwargs: Additional keyword arguments are used in the construction of an
|
|
66
|
+
`httpx.AsyncHTTPTransport`.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
A default instance of the class created from the given arguments.
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
return cls(
|
|
73
|
+
repository=repository,
|
|
74
|
+
transport=httpx.AsyncHTTPTransport(**kwargs), # type: ignore[arg-type]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def handle_async_request(
|
|
78
|
+
self,
|
|
79
|
+
request: httpx.Request,
|
|
80
|
+
) -> httpx.Response:
|
|
81
|
+
"""Handle an asynchronous request with rate limiting."""
|
|
82
|
+
limiter = self._repo.get(request)
|
|
83
|
+
async with limiter:
|
|
84
|
+
return await self._transport.handle_async_request(request)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|