Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-requests-ratelimiter for
openSUSE:Factory checked in at 2026-04-25 23:27:56
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-requests-ratelimiter (Old)
and /work/SRC/openSUSE:Factory/.python-requests-ratelimiter.new.11940
(New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-requests-ratelimiter"
Sat Apr 25 23:27:56 2026 rev:2 rq:1349271 version:0.10.0
Changes:
--------
---
/work/SRC/openSUSE:Factory/python-requests-ratelimiter/python-requests-ratelimiter.changes
2026-04-15 20:42:11.666393414 +0200
+++
/work/SRC/openSUSE:Factory/.python-requests-ratelimiter.new.11940/python-requests-ratelimiter.changes
2026-04-25 23:28:14.757646985 +0200
@@ -1,0 +2,11 @@
+Sat Apr 25 19:53:25 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 0.10.0:
+ * Add max_delay parameter compatible with pyrate-limiter v4
+ * Fix per-host rate-limiting for Redis and Postgres backends
+ * If both per_host=True and a bucket_name is specified, use
+ bucket_name as a bucket prefix
+ * Add warning if a custom Limiter object is passed with
+ per_host=True and no HostBucketFactory
+
+-------------------------------------------------------------------
Old:
----
requests_ratelimiter-0.9.3.tar.gz
New:
----
requests_ratelimiter-0.10.0.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-requests-ratelimiter.spec ++++++
--- /var/tmp/diff_new_pack.2OwxKx/_old 2026-04-25 23:28:15.313669728 +0200
+++ /var/tmp/diff_new_pack.2OwxKx/_new 2026-04-25 23:28:15.313669728 +0200
@@ -17,21 +17,21 @@
Name: python-requests-ratelimiter
-Version: 0.9.3
+Version: 0.10.0
Release: 0
Summary: Easy rate-limiting for python requests
License: MIT
URL: https://github.com/JWCook/requests-ratelimiter
Source:
https://files.pythonhosted.org/packages/source/r/requests_ratelimiter/requests_ratelimiter-%{version}.tar.gz
BuildRequires: %{python_module base >= 3.10}
-BuildRequires: %{python_module pip}
BuildRequires: %{python_module hatchling}
+BuildRequires: %{python_module pip}
+BuildRequires: %{python_module pyrate-limiter >= 4.1 and
%python-pyrate-limiter < 5}
BuildRequires: %{python_module pytest >= 8.3}
BuildRequires: %{python_module pytest-xdist >= 3.1}
BuildRequires: %{python_module requests >= 2.20}
BuildRequires: %{python_module requests-cache >= 1.2.0}
BuildRequires: %{python_module requests-mock >= 1.11}
-BuildRequires: %{python_module pyrate-limiter >= 4.1 and
%python-pyrate-limiter < 5}
Requires: python-requests >= 2.20
Requires: (python-pyrate-limiter >= 4.1 and python-pyrate-limiter < 5)
%python_subpackages
++++++ requests_ratelimiter-0.9.3.tar.gz -> requests_ratelimiter-0.10.0.tar.gz
++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/requests_ratelimiter-0.9.3/PKG-INFO
new/requests_ratelimiter-0.10.0/PKG-INFO
--- old/requests_ratelimiter-0.9.3/PKG-INFO 2020-02-02 01:00:00.000000000
+0100
+++ new/requests_ratelimiter-0.10.0/PKG-INFO 2020-02-02 01:00:00.000000000
+0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: requests-ratelimiter
-Version: 0.9.3
+Version: 0.10.0
Summary: Rate-limiting for the requests library
Project-URL: homepage, https://github.com/JWCook/requests-ratelimiter
Project-URL: repository, https://github.com/JWCook/requests-ratelimiter
@@ -149,16 +149,20 @@
<!-- TODO: Section explaining burst rate limit -->
### Advanced Settings
-If you need to define more complex rate limits, you can create a `Limiter`
object instead:
-```python
-from pyrate_limiter import Duration, RequestRate, Limiter
-from requests_ratelimiter import LimiterSession
+If you need to define more complex rate limits, you can use `Limiter` directly.
+Note that it must be used with `HostBucketFactory` if you want per-host
rate-limiting.
-nanocentury_rate = RequestRate(10, Duration.SECOND * 3.156)
-fortnight_rate = RequestRate(1000, Duration.DAY * 14)
-trimonthly_rate = RequestRate(10000, Duration.MONTH * 3)
-limiter = Limiter(nanocentury_rate, fortnight_rate, trimonthly_rate)
+```python
+from pyrate_limiter import Duration, Rate, Limiter
+from requests_ratelimiter import LimiterSession, HostBucketFactory
+nanocentury_rate = Rate(10, Duration.SECOND * 3.156)
+fortnight_rate = Rate(1000, Duration.DAY * 14)
+trimonthly_rate = Rate(10000, Duration.DAY * 90)
+
+# This factory object is required for per-host rate-limiting
+factory = HostBucketFactory(rates=[nanocentury_rate, fortnight_rate,
trimonthly_rate])
+limiter = Limiter(factory)
session = LimiterSession(limiter=limiter)
```
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/requests_ratelimiter-0.9.3/README.md
new/requests_ratelimiter-0.10.0/README.md
--- old/requests_ratelimiter-0.9.3/README.md 2020-02-02 01:00:00.000000000
+0100
+++ new/requests_ratelimiter-0.10.0/README.md 2020-02-02 01:00:00.000000000
+0100
@@ -123,16 +123,20 @@
<!-- TODO: Section explaining burst rate limit -->
### Advanced Settings
-If you need to define more complex rate limits, you can create a `Limiter`
object instead:
+If you need to define more complex rate limits, you can use `Limiter` directly.
+Note that it must be used with `HostBucketFactory` if you want per-host
rate-limiting.
+
```python
-from pyrate_limiter import Duration, RequestRate, Limiter
-from requests_ratelimiter import LimiterSession
+from pyrate_limiter import Duration, Rate, Limiter
+from requests_ratelimiter import LimiterSession, HostBucketFactory
-nanocentury_rate = RequestRate(10, Duration.SECOND * 3.156)
-fortnight_rate = RequestRate(1000, Duration.DAY * 14)
-trimonthly_rate = RequestRate(10000, Duration.MONTH * 3)
-limiter = Limiter(nanocentury_rate, fortnight_rate, trimonthly_rate)
+nanocentury_rate = Rate(10, Duration.SECOND * 3.156)
+fortnight_rate = Rate(1000, Duration.DAY * 14)
+trimonthly_rate = Rate(10000, Duration.DAY * 90)
+# This factory object is required for per-host rate-limiting
+factory = HostBucketFactory(rates=[nanocentury_rate, fortnight_rate,
trimonthly_rate])
+limiter = Limiter(factory)
session = LimiterSession(limiter=limiter)
```
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/requests_ratelimiter-0.9.3/pyproject.toml
new/requests_ratelimiter-0.10.0/pyproject.toml
--- old/requests_ratelimiter-0.9.3/pyproject.toml 2020-02-02
01:00:00.000000000 +0100
+++ new/requests_ratelimiter-0.10.0/pyproject.toml 2020-02-02
01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
[project]
name = 'requests-ratelimiter'
-version = '0.9.3'
+version = '0.10.0'
description = 'Rate-limiting for the requests library'
authors = [{name = 'Jordan Cook'}]
license = 'MIT'
@@ -72,6 +72,11 @@
[tool.mypy]
ignore_missing_imports = true
+[tool.pytest.ini_options]
+markers = [
+ 'integration: marks tests that run against a local HTTP server (deselect
with -m "not integration")',
+]
+
[tool.ruff]
fix = true
unsafe-fixes = true
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/requests_ratelimiter-0.9.3/requests_ratelimiter/buckets.py
new/requests_ratelimiter-0.10.0/requests_ratelimiter/buckets.py
--- old/requests_ratelimiter-0.9.3/requests_ratelimiter/buckets.py
2020-02-02 01:00:00.000000000 +0100
+++ new/requests_ratelimiter-0.10.0/requests_ratelimiter/buckets.py
2020-02-02 01:00:00.000000000 +0100
@@ -1,3 +1,4 @@
+import re
from typing import Dict, Optional, Type
from pyrate_limiter import InMemoryBucket, PostgresBucket, Rate, RedisBucket,
SQLiteBucket
@@ -33,44 +34,41 @@
"""Get or create a bucket for the given item name"""
if item.name not in self.buckets:
# Create new bucket for this name
- bucket = self._create_bucket()
+ bucket = self._create_bucket(item.name)
self.schedule_leak(bucket)
self.buckets[item.name] = bucket
return self.buckets[item.name]
- def _create_bucket(self) -> AbstractBucket:
- """Create a new bucket instance with the configured bucket class"""
+ def _create_bucket(self, name: str) -> AbstractBucket:
+ """Create a new bucket instance, and handle per-host naming for each
supported backend"""
if self.bucket_class == InMemoryBucket:
return InMemoryBucket(self.rates)
elif self.bucket_class == SQLiteBucket:
- kwargs = prepare_sqlite_kwargs(self.bucket_init_kwargs,
self.bucket_name)
+ kwargs = prepare_sqlite_kwargs(self.bucket_init_kwargs, name)
return SQLiteBucket.init_from_file(rates=self.rates, **kwargs)
elif self.bucket_class == RedisBucket:
kwargs = self.bucket_init_kwargs.copy()
- bucket_key = kwargs.pop('bucket_key', self.bucket_name or
'default')
redis = kwargs.pop('redis')
- return RedisBucket.init(rates=self.rates, redis=redis,
bucket_key=bucket_key)
+ bucket_key = kwargs.pop('bucket_key', _sanitize_name(name))
+ return RedisBucket.init(rates=self.rates, redis=redis,
bucket_key=bucket_key, **kwargs)
elif self.bucket_class == PostgresBucket:
kwargs = self.bucket_init_kwargs.copy()
pool = kwargs.pop('pool')
- table = kwargs.pop('table', self.bucket_name or 'default')
+ table = kwargs.pop('table', _sanitize_name(name))
return PostgresBucket(pool=pool, table=table, rates=self.rates)
else:
- # Generic bucket creation - pass rates as first arg
return self.bucket_class(self.rates, **self.bucket_init_kwargs)
def __getitem__(self, name: str) -> AbstractBucket:
"""Dict-like access for backward compatibility with _fill_bucket()
method"""
if name not in self.buckets:
- # Create bucket on access
temp_item = RateItem(name, 0, 1)
return self.get(temp_item)
return self.buckets[name]
def prepare_sqlite_kwargs(bucket_kwargs: Dict, bucket_name: Optional[str] =
None) -> Dict:
- """Prepare SQLiteBucket kwargs for v4 compatibility"""
kwargs = bucket_kwargs.copy()
if 'path' in kwargs:
kwargs['db_path'] = str(kwargs.pop('path'))
@@ -78,8 +76,12 @@
# If bucket_name is specified, use it as the table name to ensure
separation
# This allows multiple sessions with different bucket_names to share a db
file
if bucket_name and 'table' not in kwargs:
- kwargs['table'] = f'bucket_{bucket_name}'
+ kwargs['table'] = f'bucket_{_sanitize_name(bucket_name)}'
# Filter to only supported parameters for SQLiteBucket.init_from_file
supported_params = {'table', 'db_path', 'create_new_table',
'use_file_lock'}
return {k: v for k, v in kwargs.items() if k in supported_params}
+
+
+def _sanitize_name(name: str) -> str:
+ return re.sub(r'[^a-zA-Z0-9_]', '_', name)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/requests_ratelimiter-0.9.3/requests_ratelimiter/requests_ratelimiter.py
new/requests_ratelimiter-0.10.0/requests_ratelimiter/requests_ratelimiter.py
--- old/requests_ratelimiter-0.9.3/requests_ratelimiter/requests_ratelimiter.py
2020-02-02 01:00:00.000000000 +0100
+++
new/requests_ratelimiter-0.10.0/requests_ratelimiter/requests_ratelimiter.py
2020-02-02 01:00:00.000000000 +0100
@@ -7,7 +7,7 @@
from pyrate_limiter import Duration, InMemoryBucket, Limiter, Rate
from pyrate_limiter.abstracts import AbstractBucket, RateItem
-from requests import PreparedRequest, Response, Session
+from requests import PreparedRequest, Response, Session, exceptions
from requests.adapters import HTTPAdapter
from .buckets import HostBucketFactory
@@ -38,6 +38,7 @@
time_function: Optional[Callable[..., float]] = None,
limiter: Optional[Limiter] = None,
per_host: bool = True,
+ max_delay: Optional[float] = None,
limit_statuses: Iterable[int] = (429,),
bucket_name: Optional[str] = None,
**kwargs,
@@ -66,6 +67,11 @@
if limiter:
self.limiter = limiter
self._custom_limiter = True
+ if per_host and not isinstance(limiter.bucket_factory,
HostBucketFactory):
+ logger.warning(
+ 'Custom limiter does not use HostBucketFactory; per-host
rate limiting will '
+ 'not work. Use HostBucketFactory to enable per-host rate
limiting.'
+ )
else:
factory = HostBucketFactory(
rates=rates,
@@ -76,8 +82,7 @@
self.limiter = Limiter(factory, buffer_ms=50)
self._custom_limiter = False
- if kwargs.pop('max_delay', None):
- logger.warning('max_delay is no longer supported')
+ self.max_delay = max_delay
self.limit_statuses = limit_statuses
self.per_host = per_host
self.bucket_name = bucket_name
@@ -91,7 +96,10 @@
def send(self, request: PreparedRequest, **kwargs) -> Response:
"""Send a request with rate-limiting."""
bucket_name = self._bucket_name(request)
- self.limiter.try_acquire(bucket_name, weight=1, blocking=True)
+ timeout = self.max_delay if self.max_delay is not None else -1
+ acquired = self.limiter.try_acquire(bucket_name, weight=1,
blocking=True, timeout=timeout)
+ if not acquired:
+ raise exceptions.Timeout(f'Rate limit not cleared within
max_delay={self.max_delay}s')
response = super().send(request, **kwargs)
if response.status_code in self.limit_statuses:
@@ -101,10 +109,11 @@
def _bucket_name(self, request):
"""Get a bucket name for the given request"""
- if self.bucket_name:
+ if self.per_host:
+ host = urlparse(request.url).netloc
+ return f'{self.bucket_name}:{host}' if self.bucket_name else host
+ elif self.bucket_name:
return self.bucket_name
- elif self.per_host:
- return urlparse(request.url).netloc
else:
return self._default_bucket
@@ -188,16 +197,22 @@
:py:class:`~pyrate_limiter.buckets.redis_bucket.RedisBucket`
bucket_kwargs: Bucket backend keyword arguments
limiter: An existing Limiter object to use instead of the above params
- per_host: Track request rate limits separately for each host
limit_statuses: Alternative HTTP status codes that indicate a rate
limit was exceeded
+ per_host: Track request rate limits separately for each host
+ max_delay: Maximum time (in seconds) to wait for a rate-limited
request. If the rate limit
+ is not cleared within this time, raises
:py:exc:`requests.exceptions.Timeout`.
+ ``None`` = wait indefinitely.
+ bucket_name: Override default bucket name. In per-host mode, this sets
the bucket prefix.
"""
__attrs__ = Session.__attrs__ + [
'limiter',
'limit_statuses',
+ 'max_delay',
'per_host',
'bucket_name',
'_default_bucket',
+ '_custom_limiter',
]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/requests_ratelimiter-0.9.3/test/test_buckets.py
new/requests_ratelimiter-0.10.0/test/test_buckets.py
--- old/requests_ratelimiter-0.9.3/test/test_buckets.py 1970-01-01
01:00:00.000000000 +0100
+++ new/requests_ratelimiter-0.10.0/test/test_buckets.py 2020-02-02
01:00:00.000000000 +0100
@@ -0,0 +1,136 @@
+from unittest.mock import MagicMock, patch
+
+import pytest
+from pyrate_limiter import InMemoryBucket, PostgresBucket, Rate, RedisBucket
+
+from requests_ratelimiter.buckets import HostBucketFactory,
prepare_sqlite_kwargs
+
+
+def test_in_memory_bucket_creation():
+ factory = HostBucketFactory(rates=[Rate(5, 1000)])
+ item = factory.wrap_item('test_host')
+ factory.get(item)
+ assert 'test_host' in factory.buckets
+
+
+def test_getitem_creates_bucket_on_demand():
+ factory = HostBucketFactory(rates=[Rate(5, 1000)])
+ factory['new_host']
+ assert 'new_host' in factory.buckets
+
+
+def test_getitem_returns_existing_bucket():
+ factory = HostBucketFactory(rates=[Rate(5, 1000)])
+ item = factory.wrap_item('existing_host')
+ bucket1 = factory.get(item)
+ bucket2 = factory['existing_host']
+ assert bucket1 is bucket2
+
+
+def test_wrap_item_uses_bucket_clock():
+ factory = HostBucketFactory(rates=[Rate(5, 1000)])
+ item = factory.wrap_item('test_host')
+ assert item.name == 'test_host'
+ assert item.weight == 1
+ assert item.timestamp > 0
+
+
+def test_wrap_item_custom_weight():
+ factory = HostBucketFactory(rates=[Rate(5, 1000)])
+ item = factory.wrap_item('test_host', weight=3)
+ assert item.weight == 3
+
+
+def test_separate_buckets_per_name():
+ factory = HostBucketFactory(rates=[Rate(5, 1000)])
+ item_a = factory.wrap_item('host_a')
+ item_b = factory.wrap_item('host_b')
+ bucket_a = factory.get(item_a)
+ bucket_b = factory.get(item_b)
+ assert bucket_a is not bucket_b
+ assert len(factory.buckets) == 2
+
+
[email protected](
+ 'extra_init_kwargs, identity, expected_key',
+ [
+ ({}, 'api.example.com', 'api_example_com'),
+ ({'bucket_key': 'override'}, 'api.example.com', 'override'),
+ ({}, 'myapp:api.example.com', 'myapp_api_example_com'),
+ ],
+)
+def test_redis_bucket(extra_init_kwargs, identity, expected_key):
+ mock_redis = MagicMock()
+ mock_redis.script_load.return_value = 'fake_sha1'
+ factory = HostBucketFactory(
+ rates=[Rate(5, 1000)],
+ bucket_class=RedisBucket,
+ bucket_init_kwargs={'redis': mock_redis, **extra_init_kwargs},
+ )
+
+ with patch.object(RedisBucket, 'init', wraps=RedisBucket.init) as
mock_init:
+ factory._create_bucket(identity)
+
+ mock_init.assert_called_once_with(
+ rates=factory.rates, redis=mock_redis, bucket_key=expected_key
+ )
+
+
+def _postgres_init_stub(self, pool, table, rates):
+ self.rates = rates
+ self.failing_rate = None
+
+
[email protected](
+ 'extra_init_kwargs, identity, expected_table',
+ [
+ ({}, 'api.example.com', 'api_example_com'),
+ ({'table': 'override'}, 'api.example.com', 'override'),
+ ({}, 'myapp:api.example.com', 'myapp_api_example_com'),
+ ],
+)
+def test_postgres_bucket(extra_init_kwargs, identity, expected_table):
+ mock_pool = MagicMock()
+ factory = HostBucketFactory(
+ rates=[Rate(5, 1000)],
+ bucket_class=PostgresBucket,
+ bucket_init_kwargs={'pool': mock_pool, **extra_init_kwargs},
+ )
+
+ with patch.object(
+ PostgresBucket,
+ '__init__',
+ autospec=True,
+ return_value=None,
+ side_effect=_postgres_init_stub,
+ ) as mock_init:
+ factory._create_bucket(identity)
+
+ _, kwargs = mock_init.call_args
+ assert kwargs == {'pool': mock_pool, 'table': expected_table, 'rates':
factory.rates}
+
+
+def test_generic_bucket_creation():
+ class CustomBucket(InMemoryBucket):
+ pass
+
+ factory = HostBucketFactory(
+ rates=[Rate(5, 1000)],
+ bucket_class=CustomBucket,
+ )
+ bucket = factory._create_bucket('test_host')
+ assert isinstance(bucket, CustomBucket)
+
+
[email protected](
+ 'kwargs, bucket_name, expected',
+ [
+ ({'path': '/tmp/x.db'}, None, {'db_path': '/tmp/x.db'}),
+ ({}, 'mybucket', {'table': 'bucket_mybucket'}),
+ ({'path': '/tmp/x.db', 'unknown_key': 'val'}, None, {'db_path':
'/tmp/x.db'}),
+ ({'table': 'custom'}, 'mybucket', {'table': 'custom'}),
+ ],
+)
+def test_prepare_sqlite_kwargs(kwargs, bucket_name, expected):
+ result = prepare_sqlite_kwargs(kwargs, bucket_name)
+ assert result == expected
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/requests_ratelimiter-0.9.3/test/test_exports.py
new/requests_ratelimiter-0.10.0/test/test_exports.py
--- old/requests_ratelimiter-0.9.3/test/test_exports.py 2020-02-02
01:00:00.000000000 +0100
+++ new/requests_ratelimiter-0.10.0/test/test_exports.py 2020-02-02
01:00:00.000000000 +0100
@@ -1,8 +1,6 @@
-# ruff: noqa: F403
-# Test pyrate-limiter classes exported from requests_ratelimiter namespace
(only possible in module scope)
-from requests_ratelimiter import *
+import requests_ratelimiter
-# Stub for pytest collection
def test_exports():
- pass
+ for name in requests_ratelimiter.__all__:
+ assert hasattr(requests_ratelimiter, name), f'{name} not found in
requests_ratelimiter'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/requests_ratelimiter-0.9.3/test/test_integration.py
new/requests_ratelimiter-0.10.0/test/test_integration.py
--- old/requests_ratelimiter-0.9.3/test/test_integration.py 2020-02-02
01:00:00.000000000 +0100
+++ new/requests_ratelimiter-0.10.0/test/test_integration.py 2020-02-02
01:00:00.000000000 +0100
@@ -70,17 +70,14 @@
server.shutdown()
-def _make_session(**kwargs) -> LimiterSession:
- return LimiterSession(**kwargs)
-
-
[email protected]
def test_ratelimit__respects_limit(rate_limit_server):
"""Client should self-throttle so that all requests succeed (no 429s
received).
With per_second=1 and burst=1, the client waits ~1 second between requests,
so 3 sequential requests should take at least 2 seconds total.
"""
- session = _make_session(per_second=1, burst=1)
+ session = LimiterSession(per_second=1, burst=1)
start = time.monotonic()
statuses = [session.get(rate_limit_server).status_code for _ in range(3)]
@@ -90,19 +87,20 @@
assert elapsed >= 2.0, f'Expected >= 2s for 3 requests at 1/s, got:
{elapsed:.2f}s'
[email protected]
def test_ratelimit__server_returns_429(rate_limit_server):
"""When the client bypasses rate-limiting, the server should return 429
responses"""
- # limit_statuses=[] disables the bucket-filling behavior that would
otherwise delay next request
- session = _make_session(per_second=1000, burst=1000, limit_statuses=[])
+ session = LimiterSession(per_second=1000, burst=1000, limit_statuses=[])
statuses = [session.get(rate_limit_server).status_code for _ in range(3)]
assert statuses[0] == 200, f'Expected first request to succeed, got:
{statuses[0]}'
assert 429 in statuses, f'Expected at least one 429, got: {statuses}'
[email protected]
def test_ratelimit__fill_bucket_on_429(rate_limit_server):
"""A 429 response should trigger bucket-filling, delaying the next
request"""
- session = _make_session(per_second=5, burst=5)
+ session = LimiterSession(per_second=5, burst=5)
start = time.monotonic()
statuses = [session.get(rate_limit_server).status_code for _ in range(3)]
elapsed = time.monotonic() - start
@@ -112,10 +110,11 @@
assert elapsed >= 1.0, f'Expected >= 1s delay after 429 (bucket-filling),
got: {elapsed:.2f}s'
[email protected]
def test_ratelimit__per_host_isolation(rate_limit_servers):
"""With per_host=True (default), each host has its own bucket"""
url1, url2 = rate_limit_servers
- session = _make_session(per_second=1, burst=1, per_host=True)
+ session = LimiterSession(per_second=1, burst=1, per_host=True)
# Alternate between the two hosts; neither bucket should fill up
start = time.monotonic()
@@ -129,10 +128,11 @@
assert elapsed < 2.0, f'Expected < 2s with per-host isolation, got:
{elapsed:.2f}s'
[email protected]
def test_ratelimit__per_host_disabled(rate_limit_servers):
"""With per_host=False, all hosts share a single bucket"""
url1, url2 = rate_limit_servers
- session = _make_session(per_second=1, burst=1, per_host=False)
+ session = LimiterSession(per_second=1, burst=1, per_host=False)
# Alternate between two hosts; the shared bucket throttles after the first
request
start = time.monotonic()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/requests_ratelimiter-0.9.3/test/test_requests_ratelimiter.py
new/requests_ratelimiter-0.10.0/test/test_requests_ratelimiter.py
--- old/requests_ratelimiter-0.9.3/test/test_requests_ratelimiter.py
2020-02-02 01:00:00.000000000 +0100
+++ new/requests_ratelimiter-0.10.0/test/test_requests_ratelimiter.py
2020-02-02 01:00:00.000000000 +0100
@@ -12,17 +12,14 @@
Duration,
InMemoryBucket,
Limiter,
- PostgresBucket,
Rate,
- RedisBucket,
SQLiteBucket,
)
-from requests import PreparedRequest, Session
+from requests import PreparedRequest, Session, exceptions
from requests_cache import CacheMixin
-from requests_ratelimiter import LimiterMixin, LimiterSession
-from requests_ratelimiter.buckets import HostBucketFactory,
prepare_sqlite_kwargs
-from requests_ratelimiter.requests_ratelimiter import _convert_rate
+from requests_ratelimiter import HostBucketFactory, LimiterMixin,
LimiterSession
+from requests_ratelimiter.requests_ratelimiter import _convert_rate,
_get_valid_kwargs
from test.conftest import (
MOCKED_URL,
MOCKED_URL_429,
@@ -51,7 +48,6 @@
lambda: LimiterSession(per_second=5),
lambda: CustomSession(per_second=5, flag=True),
],
- ids=['LimiterSession', 'CustomSession'],
)
def test_rate_limit_enforcement(mock_sleep, session_factory):
session = mount_mock_adapter(session_factory())
@@ -96,6 +92,40 @@
@patch_sleep
+def test_custom_limiter__per_host(mock_sleep):
+ factory = HostBucketFactory(rates=[Rate(5, Duration.SECOND)])
+ limiter = Limiter(factory)
+ session = get_mock_session(limiter=limiter, per_host=True)
+
+ for _ in range(5):
+ session.get(MOCKED_URL)
+ assert mock_sleep.called is False
+
+ # A different host should not be affected
+ session.get(MOCKED_URL_ALT_HOST)
+ assert mock_sleep.called is False
+
+ session.get(MOCKED_URL)
+ assert mock_sleep.called is True
+
+
[email protected](
+ 'session_kwargs, expect_warning',
+ [
+ ({'per_host': True}, True),
+ ({'per_host': False}, False),
+ ],
+)
+def test_custom_limiter__per_host_warning(caplog, session_kwargs,
expect_warning):
+ """Warn when a custom limiter without HostBucketFactory is used with
per_host=True"""
+ bucket = InMemoryBucket([Rate(5, Duration.SECOND)])
+ limiter = Limiter(bucket)
+ with caplog.at_level('WARNING', logger='requests_ratelimiter'):
+ LimiterSession(limiter=limiter, **session_kwargs)
+ assert ('HostBucketFactory' in caplog.text) == expect_warning
+
+
+@patch_sleep
@pytest.mark.parametrize(
'url, session_kwargs, expect_sleep',
[
@@ -138,6 +168,9 @@
(0.5, 1, 1, 2),
(1, 0.5, 2, 1), # 1 req/0.5ms -> 2 req/1ms
(0.001, 1, 1, 1000),
+ (1, 1000, 1, 1000),
+ (100, 1, 100, 1),
+ (10, 10, 10, 10),
],
)
def test_convert_rate(limit, interval, expected_limit, expected_interval):
@@ -148,7 +181,6 @@
@patch_sleep
def test_sqlite_backend(mock_sleep, tmp_path):
- """Check that the SQLite backend works as expected"""
session = get_mock_session(
per_second=5,
bucket_class=SQLiteBucket,
@@ -197,10 +229,7 @@
"""Check that caching integration works as expected"""
class CachedLimiterSession(CacheMixin, LimiterMixin, Session):
- """
- Session class with caching and rate-limiting behavior. Accepts
arguments for both
- LimiterSession and CachedSession.
- """
+ pass
cache_path = tmp_path_factory.mktemp('pytest') / 'cache.db'
ratelimit_path = tmp_path_factory.mktemp('pytest') / 'rate_limit.db'
@@ -224,7 +253,7 @@
assert session.headers is not None
assert session.cookies is not None
assert session.auth is None # Session default
- assert session.hooks is not None
+ assert 'response' in session.hooks
# Tests for lifecycle of bucket factory leaker thread:
@@ -246,7 +275,6 @@
def test_limiter_adapter_close_stops_leaker(limiter_adapter_session: tuple) ->
None:
- """LimiterAdapter.close() stops the Leaker thread."""
session, adapter = limiter_adapter_session
assert adapter.limiter.bucket_factory._leaker is None # no thread before
first request
@@ -260,7 +288,6 @@
def test_limiter_session_close_stops_leaker():
- """LimiterSession.close() stops the Leaker thread spawned on the first
request."""
session = get_mock_session(per_second=5)
assert session.limiter.bucket_factory._leaker is None # no thread before
first request
@@ -274,7 +301,6 @@
def test_limiter_session_context_manager_stops_leaker():
- """Using LimiterSession as a context manager stops the Leaker on
__exit__."""
with get_mock_session(per_second=5) as session:
session.get(MOCKED_URL)
leaker = session.limiter.bucket_factory._leaker
@@ -284,7 +310,6 @@
def test_session_close_cascades_to_limiter_adapter(limiter_adapter_session:
tuple) -> None:
- """Closing a Session cascades to LimiterAdapter.close(), stopping the
Leaker."""
session, adapter = limiter_adapter_session
session.get(MOCKED_URL)
@@ -297,7 +322,6 @@
def test_close_before_any_request_and_idempotent():
- """close() before any request is a safe no-op; calling it twice does not
raise."""
session = LimiterSession(per_second=5)
assert session.limiter.bucket_factory._leaker is None
session.close() # no Leaker was ever created — must not raise
@@ -306,17 +330,15 @@
@patch_sleep
def test_fill_bucket_with_custom_limiter(mock_sleep):
- """_fill_bucket falls back to limiter.buckets() when a custom Limiter is
provided"""
bucket = InMemoryBucket([Rate(5, Duration.SECOND)])
limiter = Limiter(bucket)
- session = get_mock_session(limiter=limiter)
+ session = get_mock_session(limiter=limiter, per_host=False)
session.get(MOCKED_URL_429)
session.get(MOCKED_URL_429)
- assert mock_sleep.called
+ assert mock_sleep.called is True
def test_fill_bucket_no_bucket_logs_warning(caplog):
- """_fill_bucket with no available bucket logs a warning and returns
cleanly"""
mock_limiter = MagicMock()
del mock_limiter.bucket_factory.__getitem__ # no dict-like access
mock_limiter.buckets.return_value = []
@@ -332,42 +354,6 @@
assert 'No buckets available' in caplog.text
-def test_redis_bucket():
- mock_redis = MagicMock()
- mock_redis.script_load.return_value = 'fake_sha1'
- factory = HostBucketFactory(
- rates=[Rate(5, 1000)],
- bucket_class=RedisBucket,
- bucket_init_kwargs={'redis': mock_redis, 'bucket_key': 'test_bucket'},
- )
-
- with patch.object(RedisBucket, 'init', wraps=RedisBucket.init) as
mock_init:
- factory._create_bucket()
-
- mock_init.assert_called_once_with(
- rates=factory.rates, redis=mock_redis, bucket_key='test_bucket'
- )
-
-
-def test_postgres_bucket():
- mock_pool = MagicMock()
- factory = HostBucketFactory(
- rates=[Rate(5, 1000)],
- bucket_class=PostgresBucket,
- bucket_init_kwargs={'pool': mock_pool, 'table': 'test_table'},
- )
-
- with patch.object(PostgresBucket, '__init__', autospec=True,
return_value=None) as mock_init:
- mock_init.side_effect = lambda self, pool, table, rates: (
- setattr(self, 'rates', rates) or setattr(self, 'failing_rate',
None)
- )
- factory._create_bucket()
-
- mock_init.assert_called_once()
- _, kwargs = mock_init.call_args
- assert kwargs == {'pool': mock_pool, 'table': 'test_table', 'rates':
factory.rates}
-
-
@patch_sleep
def test_custom_bucket_class(mock_sleep):
class MyBucket(InMemoryBucket):
@@ -379,39 +365,27 @@
assert mock_sleep.called is False
session.get(MOCKED_URL)
assert mock_sleep.called is True
+ session.close()
-def test_bucket_name_overrides_per_host():
- """Explicit bucket_name takes priority over per_host=True"""
- session = LimiterSession(per_second=5, bucket_name='fixed', per_host=True)
+def test_bucket_name_prefixes_per_host():
+ session = LimiterSession(per_second=5, bucket_name='myapp', per_host=True)
req = PreparedRequest()
req.url = MOCKED_URL
- assert session._bucket_name(req) == 'fixed'
+ assert session._bucket_name(req) == 'myapp:requests-ratelimiter.com'
[email protected](
- 'kwargs, bucket_name, expected',
- [
- ({'path': '/tmp/x.db'}, None, {'db_path': '/tmp/x.db'}),
- ({}, 'mybucket', {'table': 'bucket_mybucket'}),
- ({'path': '/tmp/x.db', 'unknown_key': 'val'}, None, {'db_path':
'/tmp/x.db'}),
- ({'table': 'custom'}, 'mybucket', {'table': 'custom'}), # explicit
table not overridden
- ],
-)
-def test_prepare_sqlite_kwargs(kwargs, bucket_name, expected):
- result = prepare_sqlite_kwargs(kwargs, bucket_name)
- assert result == expected
-
-
-def test_max_delay_logs_warning(caplog):
- with caplog.at_level('WARNING', logger='requests_ratelimiter'):
- LimiterSession(per_second=5, max_delay=10)
- assert 'max_delay' in caplog.text
+@patch_sleep
+def test_max_delay_raises_on_timeout(mock_sleep):
+ session = get_mock_session(per_second=5, max_delay=0.001)
+ for _ in range(5):
+ session.get(MOCKED_URL)
+ with pytest.raises(exceptions.Timeout, match='max_delay'):
+ session.get(MOCKED_URL)
@patch_sleep
def test_burst_allows_consecutive_requests(mock_sleep):
- """burst=3 allows 3 rapid consecutive requests before enforcing per-second
limit"""
session = get_mock_session(per_second=1, burst=3)
for _ in range(3):
session.get(MOCKED_URL)
@@ -427,13 +401,10 @@
(InMemoryBucket, {}), # InMemoryBucket
(SQLiteBucket, None), # SQLiteBucket (will use fixture to provide
kwargs)
],
- ids=['in_memory', 'sqlite'],
)
def test_pickling(mock_sleep, bucket_class, bucket_kwargs, tmp_path):
if bucket_class == SQLiteBucket:
bucket_kwargs = {'path': tmp_path / 'rate_limit.db',
**SQLITE_BUCKET_KWARGS}
- elif bucket_kwargs is None:
- bucket_kwargs = {}
session = get_mock_session(per_second=5, bucket_class=bucket_class,
bucket_kwargs=bucket_kwargs)
unpickled = pickle.loads(pickle.dumps(session))
@@ -449,3 +420,151 @@
assert mock_sleep.called is False
unpickled.get(MOCKED_URL)
assert mock_sleep.called is True
+
+
+def test_pickling_custom_limiter():
+ """Unpickling a session with a custom limiter should preserve
_custom_limiter so that
+ close() does not try to stop a factory it doesn't own."""
+ bucket = InMemoryBucket([Rate(5, Duration.SECOND)])
+ limiter = Limiter(bucket)
+ session = get_mock_session(limiter=limiter, per_host=False)
+ unpickled = pickle.loads(pickle.dumps(session))
+ assert unpickled._custom_limiter is True
+ unpickled.close() # must not raise
+
+
+@patch_sleep
[email protected]('method', ['get', 'post', 'put', 'patch', 'delete',
'head', 'options'])
+def test_rate_limiting_applies_to_all_http_methods(mock_sleep, method):
+ session = get_mock_session(per_second=5)
+ http_method = getattr(session, method)
+ for _ in range(5):
+ http_method(MOCKED_URL)
+ assert mock_sleep.called is False
+ http_method(MOCKED_URL)
+ assert mock_sleep.called is True
+
+
+@patch_sleep
+def test_combined_rate_limits(mock_sleep):
+ session = get_mock_session(per_second=5, per_minute=10)
+
+ for _ in range(5):
+ session.get(MOCKED_URL)
+ assert mock_sleep.called is False
+
+ session.get(MOCKED_URL)
+ assert mock_sleep.called is True
+
+
[email protected](
+ 'kwargs, expected_limit',
+ [
+ ({'per_hour': 100}, 100),
+ ({'per_day': 1000}, 1000),
+ ({'per_month': 10000}, 10000),
+ ],
+)
+def test_rate_limit_period(kwargs, expected_limit):
+ session = LimiterSession(**kwargs)
+ factory = session.limiter.bucket_factory
+ assert len(factory.rates) == 1
+ assert factory.rates[0].limit == expected_limit
+
+
+def test_no_rate_limits_no_limiter():
+ session = LimiterSession()
+ assert session.limiter is not None
+ factory = session.limiter.bucket_factory
+ assert len(factory.rates) == 0
+
+
[email protected](
+ 'url, bucket_name, expected_name',
+ [
+ ('http+mock://example.com/path', None, 'example.com'),
+ ('http+mock://example.com:8080/path', None, 'example.com:8080'),
+ ('http+mock://192.168.1.1/path', None, '192.168.1.1'),
+ ('http+mock://[::1]/path', None, '[::1]'),
+ ('http+mock://[::1]:8080/path', None, '[::1]:8080'),
+ ('http+mock://example.com/path', 'myapp', 'myapp:example.com'),
+ ],
+)
+def test_bucket_name_from_url(url, bucket_name, expected_name):
+ """per_host bucket names are derived from URL netloc, including ports and
IPs"""
+ session = LimiterSession(per_second=5, per_host=True,
bucket_name=bucket_name)
+ req = PreparedRequest()
+ req.url = url
+ assert session._bucket_name(req) == expected_name
+
+
+def test_custom_limiter_close_does_not_stop_factory():
+ bucket = InMemoryBucket([Rate(5, Duration.SECOND)])
+ limiter = Limiter(bucket)
+ session = get_mock_session(limiter=limiter, per_host=False)
+ session.get(MOCKED_URL)
+ session.close()
+
+
+@patch_sleep
+def test_limiter_adapter_per_host(mock_sleep, limiter_adapter_session: tuple)
-> None:
+ session, adapter = limiter_adapter_session
+
+ for _ in range(5):
+ session.get(MOCKED_URL)
+ assert mock_sleep.called is False
+
+ session.get(MOCKED_URL_ALT_HOST)
+ assert mock_sleep.called is False
+
+ session.get(MOCKED_URL)
+ assert mock_sleep.called is True
+
+
+@patch_sleep
+def test_shared_bucket_name_enforces_shared_limit(mock_sleep, tmp_path):
+ ratelimit_path = tmp_path / 'rate_limit.db'
+
+ session_a = get_mock_session(
+ per_second=5,
+ bucket_name='shared',
+ bucket_class=SQLiteBucket,
+ bucket_kwargs={'path': ratelimit_path, **SQLITE_BUCKET_KWARGS},
+ )
+ session_b = get_mock_session(
+ per_second=5,
+ bucket_name='shared',
+ bucket_class=SQLiteBucket,
+ bucket_kwargs={'path': ratelimit_path, **SQLITE_BUCKET_KWARGS},
+ )
+
+ for _ in range(5):
+ session_a.get(MOCKED_URL)
+ assert mock_sleep.called is False
+
+ session_b.get(MOCKED_URL)
+ assert mock_sleep.called is True
+
+
+@patch_sleep
+def test_limit_statuses_multiple_codes(mock_sleep):
+ session = get_mock_session(per_second=5, limit_statuses=[429, 500])
+
+ session.get(MOCKED_URL_500)
+ assert mock_sleep.called is False
+
+ session.get(MOCKED_URL_500)
+ assert mock_sleep.called is True
+
+
[email protected](
+ 'func, kwargs, expected',
+ [
+ (lambda x, y: None, {'x': 1, 'y': 2, 'z': 3}, {'x': 1, 'y': 2}),
+ (lambda x: None, {'x': 1, 'y': 2}, {'x': 1}),
+ (lambda: None, {'x': 1}, {}),
+ (lambda x, y=None: None, {'x': 1, 'y': None}, {'x': 1}),
+ ],
+)
+def test_get_valid_kwargs(func, kwargs, expected):
+ assert _get_valid_kwargs(func, kwargs) == expected