httpx-limiter 0.3.0__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpx-limiter
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -26,17 +26,18 @@ Classifier: Programming Language :: Python :: 3.13
26
26
  Classifier: Topic :: Internet :: WWW/HTTP
27
27
  Classifier: Typing :: Typed
28
28
  Requires-Python: >=3.10
29
- Requires-Dist: aiolimiter~=1.2
30
29
  Requires-Dist: httpx~=0.25
30
+ Requires-Dist: pyrate-limiter~=3.9
31
+ Requires-Dist: typing-extensions~=4.6; python_version < '3.11'
31
32
  Description-Content-Type: text/markdown
32
33
 
33
34
  # HTTPX Limiter
34
35
 
35
- | | |
36
- |---|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
37
- | Package | [![Latest PyPI Version](https://img.shields.io/pypi/v/httpx-limiter.svg)](https://pypi.org/project/httpx-limiter/) [![Supported Python Versions](https://img.shields.io/pypi/pyversions/httpx-limiter.svg)](https://pypi.org/project/httpx-limiter/) [![Documentation](https://readthedocs.org/projects/httpx-limiter/badge/?version=latest)](https://httpx-limiter.readthedocs.io/en/latest/?badge=latest) |
38
- | Meta | [![Apache-2.0](https://img.shields.io/pypi/l/httpx-limiter.svg)](LICENSE) [![Code of Conduct](https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg)](.github/CODE_OF_CONDUCT.md) [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) [![Code Style Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) [![Linting: Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) |
39
- | Automation | |
36
+ | | |
37
+ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
38
+ | Package | [![Latest PyPI Version](https://img.shields.io/pypi/v/httpx-limiter.svg)](https://pypi.org/project/httpx-limiter/) [![Supported Python Versions](https://img.shields.io/pypi/pyversions/httpx-limiter.svg)](https://pypi.org/project/httpx-limiter/) |
39
+ | Meta | [![Apache-2.0](https://img.shields.io/pypi/l/httpx-limiter.svg)](LICENSE) [![Code of Conduct](https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg)](.github/CODE_OF_CONDUCT.md) [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) [![Code Style Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) [![Linting: Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) |
40
+ | Automation | [![CI](https://github.com/Midnighter/httpx-limiter/actions/workflows/main.yml/badge.svg)](https://github.com/Midnighter/httpx-limiter/actions/workflows/main.yml) |
40
41
 
41
42
  _A lightweight package that provides rate-limited httpx transports._
42
43
 
@@ -51,14 +52,16 @@ pip install httpx-limiter
51
52
 
52
53
  ## Tutorial
53
54
 
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,
55
+ You can limit the number of requests made by an HTTPX client using the
56
+ transports provided in this package. That is useful in situations when you need
57
+ to make a large number of asynchronous requests against endpoints that implement
58
+ a rate limit.
57
59
 
58
60
  ### Single Rate Limit
59
61
 
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:
62
+ The simplest use case is to apply a single rate limit to all requests. If you
63
+ want to be able to make twenty requests per second, for example, use the
64
+ following code:
62
65
 
63
66
  ```python
64
67
  import httpx
@@ -66,38 +69,48 @@ from httpx_limiter import AsyncRateLimitedTransport, Rate
66
69
 
67
70
  async def main():
68
71
  async with httpx.AsyncClient(
69
- transport=AsyncRateLimitedTransport.create(rate=Rate.create(magnitude=20)),
72
+ transport=AsyncRateLimitedTransport.create(Rate.create(magnitude=20)),
70
73
  ) as client:
71
74
  response = await client.get("https://httpbin.org")
72
75
  ```
73
76
 
74
77
  > [!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.
78
+ > Due to limitations in the design of the underlying [leaky
79
+ > bucket](https://en.wikipedia.org/wiki/Leaky_bucket) implementation, which is
80
+ > used to implement the rate limiting, the magnitude of the rate is also the
81
+ > maximum capacity of the bucket. That means, if you set a rate that is larger
82
+ > than one, a burst of requests equal to that capacity will be allowed. If you
83
+ > do not want to allow any bursts, set the magnitude to one, but the duration to
84
+ > the inverse of your desired rate. If you want to allow twenty requests per
85
+ > second, for example, set the magnitude to 1 and the duration to 0.05 seconds.
86
+ >
87
+ > ```python
88
+ > Rate.create(magnitude=1, duration=1/20)
89
+ > ```
82
90
 
83
91
  ### Multiple Rate Limits
84
92
 
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:
93
+ For more advanced use cases, you can apply different rate limits based on a
94
+ concrete implementation of the `AbstractRateLimiterRepository`. There are two
95
+ relevant methods that both get passed the current request. One method needs to
96
+ identify which rate limit to apply, and the other method sets the rate limit
97
+ itself. See the following example:
89
98
 
90
99
  ```python
91
100
  import httpx
92
- from httpx_limiter import AbstractRateLimiterRepository, AsyncMultiRateLimitedTransport, Rate
101
+ from httpx_limiter import (
102
+ AbstractRateLimiterRepository,
103
+ AsyncMultiRateLimitedTransport,
104
+ Rate
105
+ )
93
106
 
94
107
  class DomainBasedRateLimiterRepository(AbstractRateLimiterRepository):
95
108
  """Apply different rate limits based on the domain being requested."""
96
-
109
+
97
110
  def get_identifier(self, request: httpx.Request) -> str:
98
111
  """Return the domain as the identifier for rate limiting."""
99
112
  return request.url.host
100
-
113
+
101
114
  def get_rate(self, request: httpx.Request) -> Rate:
102
115
  """Apply the same, but independent rate limit to each domain."""
103
116
  return Rate.create(magnitude=25)
@@ -109,15 +122,16 @@ client = httpx.AsyncClient(
109
122
  )
110
123
  ```
111
124
 
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.
125
+ > [!TIP]
126
+ > You are free to ignore the request parameter and use global information like
127
+ > the time of day to determine the rate limit.
115
128
 
116
129
  ```python
117
130
  from datetime import datetime, timezone
131
+ from collections.abc import Sequence
118
132
 
119
133
  import httpx
120
- from httpx_limiter import AbstractRateLimiterRepository, AsyncMultiRateLimitedTransport, Rate
134
+ from httpx_limiter import AbstractRateLimiterRepository, Rate
121
135
 
122
136
  class DayNightRateLimiterRepository(AbstractRateLimiterRepository):
123
137
  """Apply different rate limits based on the time of day."""
@@ -126,18 +140,18 @@ class DayNightRateLimiterRepository(AbstractRateLimiterRepository):
126
140
  """Identify whether it is currently day or night."""
127
141
  if 6 <= datetime.now(tz=timezone.utc).hour < 18:
128
142
  return "day"
129
-
143
+
130
144
  return "night"
131
145
 
132
- def get_rate(self, _: httpx.Request) -> Rate:
146
+ def get_rates(self, _: httpx.Request) -> Sequence[Rate]:
133
147
  """Apply different rate limits during the day or night."""
134
148
  if self.get_identifier(_) == "day":
135
- return Rate.create(magnitude=10)
136
-
137
- return Rate.create(magnitude=100)
149
+ return [Rate.create(magnitude=10)]
150
+
151
+ return [Rate.create(magnitude=100)]
138
152
  ```
139
153
 
140
154
  ## Copyright
141
155
 
142
- - Copyright © 2024,2025 Moritz E. Beber.
143
- - Free software distributed under the [Apache Software License 2.0](./LICENSE).
156
+ - Copyright © 2024, 2025 Moritz E. Beber.
157
+ - Free software distributed under the [Apache Software License 2.0](./LICENSE).
@@ -1,10 +1,10 @@
1
1
  # HTTPX Limiter
2
2
 
3
- | | |
4
- |---|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
5
- | Package | [![Latest PyPI Version](https://img.shields.io/pypi/v/httpx-limiter.svg)](https://pypi.org/project/httpx-limiter/) [![Supported Python Versions](https://img.shields.io/pypi/pyversions/httpx-limiter.svg)](https://pypi.org/project/httpx-limiter/) [![Documentation](https://readthedocs.org/projects/httpx-limiter/badge/?version=latest)](https://httpx-limiter.readthedocs.io/en/latest/?badge=latest) |
6
- | Meta | [![Apache-2.0](https://img.shields.io/pypi/l/httpx-limiter.svg)](LICENSE) [![Code of Conduct](https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg)](.github/CODE_OF_CONDUCT.md) [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) [![Code Style Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) [![Linting: Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) |
7
- | Automation | |
3
+ | | |
4
+ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
5
+ | Package | [![Latest PyPI Version](https://img.shields.io/pypi/v/httpx-limiter.svg)](https://pypi.org/project/httpx-limiter/) [![Supported Python Versions](https://img.shields.io/pypi/pyversions/httpx-limiter.svg)](https://pypi.org/project/httpx-limiter/) |
6
+ | Meta | [![Apache-2.0](https://img.shields.io/pypi/l/httpx-limiter.svg)](LICENSE) [![Code of Conduct](https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg)](.github/CODE_OF_CONDUCT.md) [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) [![Code Style Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) [![Linting: Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) |
7
+ | Automation | [![CI](https://github.com/Midnighter/httpx-limiter/actions/workflows/main.yml/badge.svg)](https://github.com/Midnighter/httpx-limiter/actions/workflows/main.yml) |
8
8
 
9
9
  _A lightweight package that provides rate-limited httpx transports._
10
10
 
@@ -19,14 +19,16 @@ pip install httpx-limiter
19
19
 
20
20
  ## Tutorial
21
21
 
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,
22
+ You can limit the number of requests made by an HTTPX client using the
23
+ transports provided in this package. That is useful in situations when you need
24
+ to make a large number of asynchronous requests against endpoints that implement
25
+ a rate limit.
25
26
 
26
27
  ### Single Rate Limit
27
28
 
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:
29
+ The simplest use case is to apply a single rate limit to all requests. If you
30
+ want to be able to make twenty requests per second, for example, use the
31
+ following code:
30
32
 
31
33
  ```python
32
34
  import httpx
@@ -34,38 +36,48 @@ from httpx_limiter import AsyncRateLimitedTransport, Rate
34
36
 
35
37
  async def main():
36
38
  async with httpx.AsyncClient(
37
- transport=AsyncRateLimitedTransport.create(rate=Rate.create(magnitude=20)),
39
+ transport=AsyncRateLimitedTransport.create(Rate.create(magnitude=20)),
38
40
  ) as client:
39
41
  response = await client.get("https://httpbin.org")
40
42
  ```
41
43
 
42
44
  > [!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.
45
+ > Due to limitations in the design of the underlying [leaky
46
+ > bucket](https://en.wikipedia.org/wiki/Leaky_bucket) implementation, which is
47
+ > used to implement the rate limiting, the magnitude of the rate is also the
48
+ > maximum capacity of the bucket. That means, if you set a rate that is larger
49
+ > than one, a burst of requests equal to that capacity will be allowed. If you
50
+ > do not want to allow any bursts, set the magnitude to one, but the duration to
51
+ > the inverse of your desired rate. If you want to allow twenty requests per
52
+ > second, for example, set the magnitude to 1 and the duration to 0.05 seconds.
53
+ >
54
+ > ```python
55
+ > Rate.create(magnitude=1, duration=1/20)
56
+ > ```
50
57
 
51
58
  ### Multiple Rate Limits
52
59
 
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:
60
+ For more advanced use cases, you can apply different rate limits based on a
61
+ concrete implementation of the `AbstractRateLimiterRepository`. There are two
62
+ relevant methods that both get passed the current request. One method needs to
63
+ identify which rate limit to apply, and the other method sets the rate limit
64
+ itself. See the following example:
57
65
 
58
66
  ```python
59
67
  import httpx
60
- from httpx_limiter import AbstractRateLimiterRepository, AsyncMultiRateLimitedTransport, Rate
68
+ from httpx_limiter import (
69
+ AbstractRateLimiterRepository,
70
+ AsyncMultiRateLimitedTransport,
71
+ Rate
72
+ )
61
73
 
62
74
  class DomainBasedRateLimiterRepository(AbstractRateLimiterRepository):
63
75
  """Apply different rate limits based on the domain being requested."""
64
-
76
+
65
77
  def get_identifier(self, request: httpx.Request) -> str:
66
78
  """Return the domain as the identifier for rate limiting."""
67
79
  return request.url.host
68
-
80
+
69
81
  def get_rate(self, request: httpx.Request) -> Rate:
70
82
  """Apply the same, but independent rate limit to each domain."""
71
83
  return Rate.create(magnitude=25)
@@ -77,15 +89,16 @@ client = httpx.AsyncClient(
77
89
  )
78
90
  ```
79
91
 
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.
92
+ > [!TIP]
93
+ > You are free to ignore the request parameter and use global information like
94
+ > the time of day to determine the rate limit.
83
95
 
84
96
  ```python
85
97
  from datetime import datetime, timezone
98
+ from collections.abc import Sequence
86
99
 
87
100
  import httpx
88
- from httpx_limiter import AbstractRateLimiterRepository, AsyncMultiRateLimitedTransport, Rate
101
+ from httpx_limiter import AbstractRateLimiterRepository, Rate
89
102
 
90
103
  class DayNightRateLimiterRepository(AbstractRateLimiterRepository):
91
104
  """Apply different rate limits based on the time of day."""
@@ -94,18 +107,18 @@ class DayNightRateLimiterRepository(AbstractRateLimiterRepository):
94
107
  """Identify whether it is currently day or night."""
95
108
  if 6 <= datetime.now(tz=timezone.utc).hour < 18:
96
109
  return "day"
97
-
110
+
98
111
  return "night"
99
112
 
100
- def get_rate(self, _: httpx.Request) -> Rate:
113
+ def get_rates(self, _: httpx.Request) -> Sequence[Rate]:
101
114
  """Apply different rate limits during the day or night."""
102
115
  if self.get_identifier(_) == "day":
103
- return Rate.create(magnitude=10)
104
-
105
- return Rate.create(magnitude=100)
116
+ return [Rate.create(magnitude=10)]
117
+
118
+ return [Rate.create(magnitude=100)]
106
119
  ```
107
120
 
108
121
  ## Copyright
109
122
 
110
- - Copyright © 2024,2025 Moritz E. Beber.
111
- - Free software distributed under the [Apache Software License 2.0](./LICENSE).
123
+ - Copyright © 2024, 2025 Moritz E. Beber.
124
+ - Free software distributed under the [Apache Software License 2.0](./LICENSE).
@@ -14,11 +14,9 @@ requires = ["hatchling", "hatch-vcs"]
14
14
  name = "httpx-limiter"
15
15
  dynamic = ["version"]
16
16
  description = "A lightweight package that provides rate-limited httpx transports."
17
- authors = [
18
- { name = "Moritz E. Beber", email = "midnighter@posteo.net" },
19
- ]
17
+ authors = [{ name = "Moritz E. Beber", email = "midnighter@posteo.net" }]
20
18
  license = "Apache-2.0"
21
- readme = {"file" = "README.md", "content-type" = "text/markdown"}
19
+ readme = { "file" = "README.md", "content-type" = "text/markdown" }
22
20
  requires-python = ">=3.10"
23
21
  # Please consult https://pypi.org/classifiers/ for a full list.
24
22
  classifiers = [
@@ -37,15 +35,11 @@ classifiers = [
37
35
  "Topic :: Internet :: WWW/HTTP",
38
36
  "Typing :: Typed",
39
37
  ]
40
- keywords = [
41
- "httpx",
42
- "limiter",
43
- "rate-limit",
44
- "leaky bucket",
45
- ]
38
+ keywords = ["httpx", "limiter", "rate-limit", "leaky bucket"]
46
39
  dependencies = [
47
40
  "httpx ~=0.25",
48
- "aiolimiter ~=1.2",
41
+ "pyrate-limiter ~=3.9",
42
+ "typing-extensions ~=4.6; python_version < '3.11'",
49
43
  ]
50
44
 
51
45
  [project.urls]
@@ -76,17 +70,12 @@ testpaths = ["tests"]
76
70
  markers = ["raises"]
77
71
 
78
72
  [tool.coverage.paths]
79
- source = [
80
- "src/httpx_limiter",
81
- "*/site-packages/httpx_limiter",
82
- ]
73
+ source = ["src/httpx_limiter", "*/site-packages/httpx_limiter"]
83
74
 
84
75
  [tool.coverage.run]
85
76
  branch = true
86
77
  parallel = true
87
- omit = [
88
- "src/httpx_limiter/_version.py",
89
- ]
78
+ omit = ["src/httpx_limiter/_version.py"]
90
79
 
91
80
  [tool.coverage.report]
92
81
  exclude_lines = ["pragma: no cover"]
@@ -98,29 +87,27 @@ line-length = 88
98
87
  [tool.ruff.lint]
99
88
  select = ["ALL"]
100
89
  ignore = [
101
- "D107", # 'Missing docstring in __init__' ignored because pydoclint wants us to document the class instead.
102
- "D203", # '1 blank line required before class docstring' ignored because we want no blank line.
103
- "D212", # 'Multi-line docstring summary should start at the first line' ignored because we want the summary to start on the second line.
104
- "D407", # 'Missing dashed underline after section' ignored because Google style docstrings don't underline.
105
- "ANN002", # 'Missing type annotation for {*args} in method'.
106
- "ANN003", # 'Missing type annotation for {*kwargs} in method'.
107
- ]
108
- exclude = [
109
- "src/httpx_limiter/_version.py",
90
+ "D107", # 'Missing docstring in __init__' ignored because pydoclint wants us to document the class instead.
91
+ "D203", # '1 blank line required before class docstring' ignored because we want no blank line.
92
+ "D212", # 'Multi-line docstring summary should start at the first line' ignored because we want the summary to start on the second line.
93
+ "D407", # 'Missing dashed underline after section' ignored because Google style docstrings don't underline.
94
+ "ANN002", # 'Missing type annotation for {*args} in method'.
95
+ "ANN003", # 'Missing type annotation for {*kwargs} in method'.
110
96
  ]
97
+ exclude = ["src/httpx_limiter/_version.py"]
111
98
 
112
99
  [tool.ruff.lint.extend-per-file-ignores]
113
100
  "__init__.py" = [
114
- "E401", # 'Multiple imports on one line'
115
- "E402", # 'Module level import not at top of file'
116
- "F401", # 'Imported but unused'
117
- "I001", # 'Import block is un-sorted or un-formatted' ignored because we may have to import in a particular, not-alphabetical order.
101
+ "E401", # 'Multiple imports on one line'
102
+ "E402", # 'Module level import not at top of file'
103
+ "F401", # 'Imported but unused'
104
+ "I001", # 'Import block is un-sorted or un-formatted' ignored because we may have to import in a particular, not-alphabetical order.
118
105
  ]
119
106
  "tests/**/*.py" = [
120
- "S101", # 'Use of assert detected' ignored because we are using pytest.
107
+ "S101", # 'Use of assert detected' ignored because we are using pytest.
121
108
  "INP001", # 'Insecure input' ignored because we are testing.
122
109
  "ANN201", # 'Missing type annotation for {return}' ignored because all tests return `None`.
123
- "PLR2004", # Magic numbers are okay in tests.
110
+ "PLR2004", # Magic numbers are okay in tests.
124
111
  ]
125
112
 
126
113
  [tool.ruff.lint.isort]
@@ -143,9 +130,7 @@ exclude = "_version.py"
143
130
 
144
131
  # Stop mypy from complaining about missing types from imports.
145
132
  [[tool.mypy.overrides]]
146
- module = [
147
- "limiter",
148
- ]
133
+ module = ["limiter"]
149
134
  ignore_missing_imports = true
150
135
 
151
136
  #[tool.pydantic-mypy]
@@ -170,12 +155,13 @@ extra-dependencies = [
170
155
  "ruff",
171
156
  ]
172
157
 
158
+ [tool.hatch.envs.dev.env-vars]
159
+ PYTHONDEVMODE = "1"
160
+ PYTHONASYNCIODEBUG = "1"
161
+
173
162
  [tool.hatch.envs.style]
174
163
  description = """Check the style of the codebase."""
175
- dependencies = [
176
- "pydoclint ~=0.6",
177
- "ruff",
178
- ]
164
+ dependencies = ["pydoclint", "ruff"]
179
165
  detached = true
180
166
  installer = "uv"
181
167
 
@@ -187,18 +173,14 @@ check = ["docstrings", "code"]
187
173
 
188
174
  [tool.hatch.envs.audit]
189
175
  description = """Check dependencies for security vulnerabilities."""
190
- extra-dependencies = [
191
- "pip-audit",
192
- ]
176
+ extra-dependencies = ["pip-audit"]
193
177
 
194
178
  [tool.hatch.envs.audit.scripts]
195
179
  check = ["pip-audit"]
196
180
 
197
181
  [tool.hatch.envs.types]
198
182
  description = """Check the static types of the codebase."""
199
- dependencies = [
200
- "mypy",
201
- ]
183
+ dependencies = ["mypy"]
202
184
 
203
185
  [tool.hatch.envs.types.scripts]
204
186
  check = "mypy src/httpx_limiter"
@@ -209,26 +191,22 @@ extra-dependencies = [
209
191
  "mkdocs-material ~=9.5",
210
192
  "mkdocstrings[python] ~=0.24",
211
193
  "mkdocs-awesome-pages-plugin ~=2.9",
194
+ "mike ~=2.1",
212
195
  ]
213
196
 
214
197
  [tool.hatch.envs.docs.scripts]
215
198
  build = "mkdocs build {args:--clean --strict}"
216
199
  serve = "mkdocs serve {args}"
200
+ deploy = "mike deploy {args}"
217
201
 
218
- #[tool.hatch.envs.install]
219
- #description = """Test the installation the package."""
220
- #dependencies = [
221
- # "pip",
222
- # "twine",
223
- #]
224
- #detached = true
202
+ [tool.hatch.envs.distribution]
203
+ description = """Test the source and wheel distribution of the package."""
204
+ dependencies = ["pip", "twine"]
205
+ detached = true
206
+ installer = "uv"
225
207
 
226
- #[tool.hatch.envs.install.scripts]
227
- #check = [
228
- # "pip check",
229
- # "hatch build {args:--clean}",
230
- # "twine check dist/*",
231
- #]
208
+ [tool.hatch.envs.distribution.scripts]
209
+ check = ["pip check", "hatch build {args:--clean}", "twine check dist/*"]
232
210
 
233
211
  [tool.hatch.envs.unit-tests]
234
212
  description = """Run the unit tests."""
@@ -240,6 +218,10 @@ extra-dependencies = [
240
218
  "pytest-raises",
241
219
  ]
242
220
 
221
+ [tool.hatch.envs.unit-tests.env-vars]
222
+ PYTHONDEVMODE = "1"
223
+ PYTHONASYNCIODEBUG = "1"
224
+
243
225
  [[tool.hatch.envs.unit-tests.matrix]]
244
226
  python = ["3.10", "3.11", "3.12", "3.13"]
245
227
 
@@ -256,6 +238,10 @@ extra-dependencies = [
256
238
  "pytest-raises",
257
239
  ]
258
240
 
241
+ [tool.hatch.envs.acceptance-tests.env-vars]
242
+ PYTHONDEVMODE = "1"
243
+ PYTHONASYNCIODEBUG = "1"
244
+
259
245
  [[tool.hatch.envs.acceptance-tests.matrix]]
260
246
  python = ["3.10", "3.11", "3.12", "3.13"]
261
247
 
@@ -15,7 +15,9 @@
15
15
 
16
16
  """Provide top level symbols."""
17
17
 
18
+ from .types import HTTPXAsyncHTTPTransportKeywordArguments
18
19
  from .rate import Number, Rate
20
+ from .async_limiter import AsyncLimiter, PyRateLimiterKeywordArguments
19
21
  from .async_rate_limited_transport import AsyncRateLimitedTransport
20
22
  from .abstract_rate_limiter_repository import AbstractRateLimiterRepository
21
23
  from .async_multi_rate_limited_transport import AsyncMultiRateLimitedTransport
@@ -1,7 +1,14 @@
1
1
  # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
3
 
4
- __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
5
12
 
6
13
  TYPE_CHECKING = False
7
14
  if TYPE_CHECKING:
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
9
16
  from typing import Union
10
17
 
11
18
  VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
12
20
  else:
13
21
  VERSION_TUPLE = object
22
+ COMMIT_ID = object
14
23
 
15
24
  version: str
16
25
  __version__: str
17
26
  __version_tuple__: VERSION_TUPLE
18
27
  version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
19
30
 
20
- __version__ = version = '0.3.0'
21
- __version_tuple__ = version_tuple = (0, 3, 0)
31
+ __version__ = version = '0.4.0'
32
+ __version_tuple__ = version_tuple = (0, 4, 0)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,74 @@
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
+ from collections.abc import Sequence
20
+
21
+ import httpx
22
+
23
+ from .async_limiter import AsyncLimiter, PyRateLimiterKeywordArguments
24
+ from .rate import Rate
25
+
26
+
27
+ class AbstractRateLimiterRepository(ABC):
28
+ """
29
+ Define the abstract repository for rate limiters.
30
+
31
+ This abstract base class provides a framework for managing rate limiters
32
+ based on HTTP requests. It maintains a cache of rate limiters and provides
33
+ methods to retrieve request-specific identifiers, rates, and limiters.
34
+
35
+ Subclasses must implement methods to determine how requests are identified
36
+ and what rate limits should be applied to them.
37
+
38
+ Methods:
39
+ get_identifier: Return a request-specific identifier.
40
+ get_rates: Return one or more request-specific rates.
41
+ get: Return a request-specific rate limiter.
42
+
43
+ """
44
+
45
+ def __init__(self, **kwargs: dict[str, object]) -> None:
46
+ super().__init__(**kwargs)
47
+ self._limiters: dict[str, AsyncLimiter] = {}
48
+
49
+ @abstractmethod
50
+ def get_identifier(self, request: httpx.Request) -> str:
51
+ """Return a request-specific identifier."""
52
+
53
+ @abstractmethod
54
+ def get_rates(self, request: httpx.Request) -> Sequence[Rate]:
55
+ """Return one or more request-specific rates."""
56
+
57
+ def _get_limiter_kwargs(
58
+ self,
59
+ request: httpx.Request, # noqa: ARG002
60
+ ) -> PyRateLimiterKeywordArguments:
61
+ """Return the keyword arguments for creating a rate limiter."""
62
+ return {}
63
+
64
+ def get(self, request: httpx.Request) -> AsyncLimiter:
65
+ """Return a request-specific rate limiter."""
66
+ identifier = self.get_identifier(request)
67
+
68
+ if identifier not in self._limiters:
69
+ self._limiters[identifier] = AsyncLimiter.create(
70
+ *self.get_rates(request),
71
+ **self._get_limiter_kwargs(request),
72
+ )
73
+
74
+ return self._limiters[identifier]
@@ -0,0 +1,121 @@
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 rate-limited transport."""
17
+
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+ from typing import TYPE_CHECKING, TypedDict
22
+
23
+
24
+ if sys.version_info < (3, 11):
25
+ from typing_extensions import Unpack
26
+ else:
27
+ from typing import Unpack
28
+
29
+ from pyrate_limiter import (
30
+ AbstractClock,
31
+ BucketAsyncWrapper,
32
+ Duration,
33
+ InMemoryBucket,
34
+ Limiter,
35
+ TimeAsyncClock,
36
+ validate_rate_list,
37
+ )
38
+ from pyrate_limiter import Rate as PyRate
39
+
40
+
41
+ if TYPE_CHECKING: # pragma: no cover
42
+ from types import TracebackType
43
+
44
+ from .rate import Rate
45
+
46
+
47
+ class PyRateLimiterKeywordArguments(TypedDict, total=False):
48
+ """Keyword arguments for the pyrate limiter."""
49
+
50
+ clock: AbstractClock
51
+ raise_when_fail: bool
52
+ max_delay: int | Duration | None
53
+ retry_until_max_delay: bool
54
+ buffer_ms: int
55
+
56
+
57
+ class AsyncLimiter:
58
+ """
59
+ Define an asynchronous limiter that composes the pyrate limiter.
60
+
61
+ This class encapsulates the creation and configuration of a pyrate-limiter
62
+ Limiter with appropriate async bucket wrapper and settings.
63
+
64
+ Args:
65
+ limiter: An instance of an asynchronous pyrate limiter.
66
+ **kwargs: Additional keyword arguments for the parent classes.
67
+
68
+ """
69
+
70
+ def __init__(self, *, limiter: Limiter, **kwargs: dict[str, object]) -> None:
71
+ super().__init__(**kwargs)
72
+ self._limiter = limiter
73
+
74
+ @classmethod
75
+ def create(
76
+ cls,
77
+ *rates: Rate,
78
+ **kwargs: Unpack[PyRateLimiterKeywordArguments],
79
+ ) -> AsyncLimiter:
80
+ """Create an instance of AsyncLimiter."""
81
+ if not rates:
82
+ msg = "At least one rate must be provided."
83
+ raise ValueError(msg)
84
+
85
+ rate_limits = [
86
+ PyRate(limit=rate.magnitude, interval=rate.in_milliseconds())
87
+ for rate in rates
88
+ ]
89
+ if not validate_rate_list(rates=rate_limits):
90
+ url = "https://pyratelimiter.readthedocs.io/en/latest/#defining-rate-limits-and-buckets"
91
+ msg = (
92
+ f"Invalid ordering of rates provided {rate_limits}. Please read "
93
+ f"{url} for more information."
94
+ )
95
+ raise ValueError(msg)
96
+
97
+ limiter = Limiter(
98
+ argument=BucketAsyncWrapper(InMemoryBucket(rate_limits)),
99
+ clock=kwargs.get("clock", TimeAsyncClock()),
100
+ raise_when_fail=kwargs.get("raise_when_fail", False),
101
+ max_delay=kwargs.get("max_delay", Duration.HOUR),
102
+ retry_until_max_delay=kwargs.get("retry_until_max_delay", False),
103
+ buffer_ms=kwargs.get("buffer_ms", 50),
104
+ )
105
+ return cls(limiter=limiter)
106
+
107
+ async def __aenter__(self) -> AsyncLimiter: # noqa: PYI034
108
+ """Acquire a token upon entering the asynchronous context."""
109
+ # Keep trying to acquire, let timeouts be handled externally.
110
+ while not (await self._limiter.try_acquire_async("httpx-limiter")):
111
+ pass # pragma: no cover
112
+
113
+ return self
114
+
115
+ async def __aexit__(
116
+ self,
117
+ _exc_type: type[BaseException] | None,
118
+ _exc_val: BaseException | None,
119
+ _exc_tb: TracebackType | None,
120
+ ) -> None:
121
+ """Exit the asynchronous context."""
@@ -17,13 +17,21 @@
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import sys
20
21
  from typing import TYPE_CHECKING
21
22
 
23
+
24
+ if sys.version_info < (3, 11):
25
+ from typing_extensions import Unpack
26
+ else:
27
+ from typing import Unpack
28
+
22
29
  import httpx
23
30
 
24
31
 
25
32
  if TYPE_CHECKING: # pragma: no cover
26
33
  from .abstract_rate_limiter_repository import AbstractRateLimiterRepository
34
+ from .types import HTTPXAsyncHTTPTransportKeywordArguments
27
35
 
28
36
 
29
37
  class AsyncMultiRateLimitedTransport(httpx.AsyncBaseTransport):
@@ -40,7 +48,7 @@ class AsyncMultiRateLimitedTransport(httpx.AsyncBaseTransport):
40
48
  *,
41
49
  repository: AbstractRateLimiterRepository,
42
50
  transport: httpx.AsyncBaseTransport,
43
- **kwargs,
51
+ **kwargs: dict[str, object],
44
52
  ) -> None:
45
53
  super().__init__(**kwargs)
46
54
  self._repo = repository
@@ -51,7 +59,7 @@ class AsyncMultiRateLimitedTransport(httpx.AsyncBaseTransport):
51
59
  cls,
52
60
  *,
53
61
  repository: AbstractRateLimiterRepository,
54
- **kwargs: dict,
62
+ **kwargs: Unpack[HTTPXAsyncHTTPTransportKeywordArguments],
55
63
  ) -> AsyncMultiRateLimitedTransport:
56
64
  """
57
65
  Create an instance of an asynchronous multiple rate-limited transport.
@@ -17,14 +17,23 @@
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import sys
20
21
  from typing import TYPE_CHECKING
21
22
 
23
+
24
+ if sys.version_info < (3, 11):
25
+ from typing_extensions import Unpack
26
+ else:
27
+ from typing import Unpack
28
+
22
29
  import httpx
23
- from aiolimiter import AsyncLimiter
30
+
31
+ from .async_limiter import AsyncLimiter
24
32
 
25
33
 
26
34
  if TYPE_CHECKING: # pragma: no cover
27
35
  from .rate import Rate
36
+ from .types import HTTPXAsyncHTTPTransportKeywordArguments
28
37
 
29
38
 
30
39
  class AsyncRateLimitedTransport(httpx.AsyncBaseTransport):
@@ -42,7 +51,7 @@ class AsyncRateLimitedTransport(httpx.AsyncBaseTransport):
42
51
  *,
43
52
  limiter: AsyncLimiter,
44
53
  transport: httpx.AsyncBaseTransport,
45
- **kwargs,
54
+ **kwargs: dict[str, object],
46
55
  ) -> None:
47
56
  super().__init__(**kwargs)
48
57
  self._limiter = limiter
@@ -51,9 +60,8 @@ class AsyncRateLimitedTransport(httpx.AsyncBaseTransport):
51
60
  @classmethod
52
61
  def create(
53
62
  cls,
54
- *,
55
- rate: Rate,
56
- **kwargs: dict,
63
+ *rates: Rate,
64
+ **kwargs: Unpack[HTTPXAsyncHTTPTransportKeywordArguments],
57
65
  ) -> AsyncRateLimitedTransport:
58
66
  """
59
67
  Create an instance of asynchronous rate-limited transport.
@@ -63,7 +71,7 @@ class AsyncRateLimitedTransport(httpx.AsyncBaseTransport):
63
71
  That transport is passed any additional keyword arguments.
64
72
 
65
73
  Args:
66
- rate: The maximum rate per interval at which bucket capacity is restored.
74
+ *rates: One or more rate limits to apply.
67
75
  **kwargs: Additional keyword arguments are used in the construction of an
68
76
  `httpx.AsyncHTTPTransport`.
69
77
 
@@ -72,10 +80,7 @@ class AsyncRateLimitedTransport(httpx.AsyncBaseTransport):
72
80
 
73
81
  """
74
82
  return cls(
75
- limiter=AsyncLimiter(
76
- max_rate=rate.magnitude,
77
- time_period=rate.in_seconds(),
78
- ),
83
+ limiter=AsyncLimiter.create(*rates),
79
84
  transport=httpx.AsyncHTTPTransport(**kwargs), # type: ignore[arg-type]
80
85
  )
81
86
 
@@ -27,19 +27,27 @@ Number = float | int
27
27
  class Rate(NamedTuple):
28
28
  """Define the rate."""
29
29
 
30
- magnitude: float
30
+ magnitude: int
31
31
  duration: timedelta
32
32
 
33
33
  @classmethod
34
- def create(cls, magnitude: Number = 1, duration: timedelta | Number = 1) -> Rate:
34
+ def create(cls, magnitude: int = 1, duration: timedelta | Number = 1) -> Rate:
35
35
  """Create a rate."""
36
- magnitude = float(magnitude)
36
+ magnitude = int(magnitude)
37
+
38
+ if not (magnitude > 0):
39
+ msg = f"Magnitude must be strictly positive (provided {magnitude=})."
40
+ raise ValueError(msg)
37
41
 
38
42
  if not isinstance(duration, timedelta):
39
43
  duration = timedelta(seconds=float(duration))
40
44
 
45
+ if not (duration > timedelta(seconds=0)):
46
+ msg = f"Duration must be strictly positive (provided {duration=})."
47
+ raise ValueError(msg)
48
+
41
49
  return cls(magnitude=magnitude, duration=duration)
42
50
 
43
- def in_seconds(self) -> float:
44
- """Return the duration in unit seconds."""
45
- return self.duration.total_seconds()
51
+ def in_milliseconds(self) -> int:
52
+ """Return the duration in unit milliseconds."""
53
+ return int(self.duration.total_seconds() * 1_000)
@@ -0,0 +1,38 @@
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 additional type definitions."""
17
+
18
+ import ssl
19
+ import typing
20
+ from typing import TypedDict
21
+
22
+ import httpx
23
+
24
+
25
+ class HTTPXAsyncHTTPTransportKeywordArguments(TypedDict, total=False):
26
+ """Keyword arguments for the httpx.AsyncHTTPTransport constructor."""
27
+
28
+ verify: ssl.SSLContext | str | bool
29
+ cert: httpx._types.CertTypes | None
30
+ trust_env: bool
31
+ http1: bool
32
+ http2: bool
33
+ limits: httpx._config.Limits
34
+ proxy: httpx._types.ProxyTypes | None
35
+ uds: str | None
36
+ local_address: str | None
37
+ retries: int
38
+ socket_options: typing.Iterable[httpx._transports.default.SOCKET_OPTION] | None
@@ -1,53 +0,0 @@
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
-
File without changes
File without changes