klyrek-http 0.1.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.
- klyrek_http-0.1.0/.gitignore +20 -0
- klyrek_http-0.1.0/PKG-INFO +45 -0
- klyrek_http-0.1.0/README.md +24 -0
- klyrek_http-0.1.0/pyproject.toml +30 -0
- klyrek_http-0.1.0/src/klyrek_http/__init__.py +8 -0
- klyrek_http-0.1.0/src/klyrek_http/client.py +86 -0
- klyrek_http-0.1.0/src/klyrek_http/py.typed +0 -0
- klyrek_http-0.1.0/src/klyrek_http/rate_limit.py +37 -0
- klyrek_http-0.1.0/tests/test_client.py +108 -0
- klyrek_http-0.1.0/tests/test_rate_limit.py +67 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
.eggs/
|
|
5
|
+
.qodo/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.env
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
*.log
|
|
17
|
+
.idea/
|
|
18
|
+
.vscode/
|
|
19
|
+
!.vscode/extensions.json
|
|
20
|
+
klyrek_output/
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: klyrek-http
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HTTP client abstraction and request management for the Klyrek ecosystem
|
|
5
|
+
Author: Klyrek Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: appsec,http,pentesting,reconnaissance,security
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Information Technology
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Security
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: httpx>=0.27
|
|
15
|
+
Requires-Dist: klyrek-core
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# klyrek-http
|
|
23
|
+
|
|
24
|
+
The HTTP layer shared by every Klyrek package that talks to a target: `klyrek-crawler`,
|
|
25
|
+
`klyrek-api`, `klyrek-tech`, `klyrek-auth`, `klyrek-js`, `klyrek-assets`, and `klyrek-headers` all
|
|
26
|
+
issue requests through `KlyrekHTTPClient` rather than calling `httpx`/`requests` directly.
|
|
27
|
+
|
|
28
|
+
Every request goes through two checks before it leaves the process:
|
|
29
|
+
|
|
30
|
+
1. **Scope enforcement** — the URL's host must be declared in the scan's
|
|
31
|
+
[`AuthorizationScope`](../klyrek-core/README.md#scope-enforcement), or the request raises
|
|
32
|
+
`ScopeViolationError` instead of firing.
|
|
33
|
+
2. **Rate limiting** — a per-host minimum interval (`KlyrekConfig.rate_limit_per_host`) keeps a
|
|
34
|
+
scan from hammering a target faster than was authorized.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from klyrek_core.config import KlyrekConfig
|
|
38
|
+
from klyrek_core.scope import AuthorizationScope
|
|
39
|
+
from klyrek_http.client import KlyrekHTTPClient
|
|
40
|
+
|
|
41
|
+
scope = AuthorizationScope(authorized_hosts=["target.com", "*.target.com"])
|
|
42
|
+
|
|
43
|
+
with KlyrekHTTPClient(scope, config=KlyrekConfig(rate_limit_per_host=5)) as client:
|
|
44
|
+
response = client.get("https://target.com/")
|
|
45
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# klyrek-http
|
|
2
|
+
|
|
3
|
+
The HTTP layer shared by every Klyrek package that talks to a target: `klyrek-crawler`,
|
|
4
|
+
`klyrek-api`, `klyrek-tech`, `klyrek-auth`, `klyrek-js`, `klyrek-assets`, and `klyrek-headers` all
|
|
5
|
+
issue requests through `KlyrekHTTPClient` rather than calling `httpx`/`requests` directly.
|
|
6
|
+
|
|
7
|
+
Every request goes through two checks before it leaves the process:
|
|
8
|
+
|
|
9
|
+
1. **Scope enforcement** — the URL's host must be declared in the scan's
|
|
10
|
+
[`AuthorizationScope`](../klyrek-core/README.md#scope-enforcement), or the request raises
|
|
11
|
+
`ScopeViolationError` instead of firing.
|
|
12
|
+
2. **Rate limiting** — a per-host minimum interval (`KlyrekConfig.rate_limit_per_host`) keeps a
|
|
13
|
+
scan from hammering a target faster than was authorized.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from klyrek_core.config import KlyrekConfig
|
|
17
|
+
from klyrek_core.scope import AuthorizationScope
|
|
18
|
+
from klyrek_http.client import KlyrekHTTPClient
|
|
19
|
+
|
|
20
|
+
scope = AuthorizationScope(authorized_hosts=["target.com", "*.target.com"])
|
|
21
|
+
|
|
22
|
+
with KlyrekHTTPClient(scope, config=KlyrekConfig(rate_limit_per_host=5)) as client:
|
|
23
|
+
response = client.get("https://target.com/")
|
|
24
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "klyrek-http"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "HTTP client abstraction and request management for the Klyrek ecosystem"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Klyrek Contributors" }]
|
|
13
|
+
keywords = ["security", "reconnaissance", "appsec", "pentesting", "http"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Information Technology",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Security",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"klyrek-core",
|
|
23
|
+
"httpx>=0.27",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest>=8.0", "ruff>=0.6", "mypy>=1.10"]
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/klyrek_http"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""klyrek-http: scope-checked, rate-limited HTTP client for the Klyrek ecosystem."""
|
|
2
|
+
|
|
3
|
+
from klyrek_http.client import KlyrekHTTPClient
|
|
4
|
+
from klyrek_http.rate_limit import RateLimiter
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = ["KlyrekHTTPClient", "RateLimiter", "__version__"]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""A scope-checked, rate-limited HTTP client shared across the Klyrek ecosystem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from klyrek_core.config import KlyrekConfig
|
|
11
|
+
from klyrek_core.logging import get_logger
|
|
12
|
+
from klyrek_core.scope import AuthorizationScope
|
|
13
|
+
from klyrek_http.rate_limit import RateLimiter
|
|
14
|
+
|
|
15
|
+
logger = get_logger("http.client")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class KlyrekHTTPClient:
|
|
19
|
+
"""Every Klyrek package that talks to a target should issue requests through this client.
|
|
20
|
+
|
|
21
|
+
It enforces the scan's :class:`AuthorizationScope` and per-host rate limit on every
|
|
22
|
+
request, so no individual package has to remember to do so itself.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
scope: AuthorizationScope,
|
|
28
|
+
config: KlyrekConfig | None = None,
|
|
29
|
+
transport: httpx.BaseTransport | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.scope = scope
|
|
32
|
+
self.config = config or KlyrekConfig()
|
|
33
|
+
min_interval = (
|
|
34
|
+
1.0 / self.config.rate_limit_per_host if self.config.rate_limit_per_host > 0 else 0.0
|
|
35
|
+
)
|
|
36
|
+
self.rate_limiter = RateLimiter(min_interval=min_interval)
|
|
37
|
+
self._client = httpx.Client(
|
|
38
|
+
headers={"User-Agent": self.config.user_agent},
|
|
39
|
+
timeout=self.config.timeout_seconds,
|
|
40
|
+
verify=self.config.verify_tls,
|
|
41
|
+
follow_redirects=True,
|
|
42
|
+
transport=transport,
|
|
43
|
+
event_hooks={"request": [self._enforce_scope_and_rate_limit]},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def _enforce_scope_and_rate_limit(self, request: httpx.Request) -> None:
|
|
47
|
+
# Registered as an httpx "request" hook rather than checked once up front, because
|
|
48
|
+
# follow_redirects=True makes httpx dispatch redirect hops as their own requests
|
|
49
|
+
# internally. A check only in request() would see the original URL and miss a
|
|
50
|
+
# redirect to an out-of-scope host.
|
|
51
|
+
url = str(request.url)
|
|
52
|
+
self.scope.check(url)
|
|
53
|
+
self.rate_limiter.wait(request.url.host)
|
|
54
|
+
|
|
55
|
+
def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
56
|
+
logger.debug("%s %s", method, url)
|
|
57
|
+
return self._client.request(method, url, **kwargs)
|
|
58
|
+
|
|
59
|
+
def get(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
60
|
+
return self.request("GET", url, **kwargs)
|
|
61
|
+
|
|
62
|
+
def post(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
63
|
+
return self.request("POST", url, **kwargs)
|
|
64
|
+
|
|
65
|
+
def put(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
66
|
+
return self.request("PUT", url, **kwargs)
|
|
67
|
+
|
|
68
|
+
def head(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
69
|
+
return self.request("HEAD", url, **kwargs)
|
|
70
|
+
|
|
71
|
+
def delete(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
72
|
+
return self.request("DELETE", url, **kwargs)
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
self._client.close()
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> KlyrekHTTPClient:
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(
|
|
81
|
+
self,
|
|
82
|
+
exc_type: type[BaseException] | None,
|
|
83
|
+
exc: BaseException | None,
|
|
84
|
+
tb: TracebackType | None,
|
|
85
|
+
) -> None:
|
|
86
|
+
self.close()
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Per-host rate limiting so a scan never exceeds its authorized request rate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class RateLimiter:
|
|
12
|
+
"""Enforces a minimum interval between requests to the same host.
|
|
13
|
+
|
|
14
|
+
Hosts are tracked independently: waiting for ``a.com`` never blocks a
|
|
15
|
+
concurrent request to ``b.com``. ``clock``/``sleep`` are injectable so
|
|
16
|
+
tests can run instantly against a fake clock.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
min_interval: float = 0.0
|
|
20
|
+
clock: Callable[[], float] = time.monotonic
|
|
21
|
+
sleep: Callable[[float], None] = time.sleep
|
|
22
|
+
_last_request_at: dict[str, float] = field(default_factory=dict, repr=False)
|
|
23
|
+
|
|
24
|
+
def wait(self, host: str) -> None:
|
|
25
|
+
"""Block, if needed, until ``min_interval`` has passed since the last request to host."""
|
|
26
|
+
if self.min_interval <= 0:
|
|
27
|
+
self._last_request_at[host] = self.clock()
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
now = self.clock()
|
|
31
|
+
last = self._last_request_at.get(host)
|
|
32
|
+
if last is not None:
|
|
33
|
+
remaining = self.min_interval - (now - last)
|
|
34
|
+
if remaining > 0:
|
|
35
|
+
self.sleep(remaining)
|
|
36
|
+
now = self.clock()
|
|
37
|
+
self._last_request_at[host] = now
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from klyrek_core.config import KlyrekConfig
|
|
5
|
+
from klyrek_core.exceptions import ScopeViolationError
|
|
6
|
+
from klyrek_core.scope import AuthorizationScope
|
|
7
|
+
from klyrek_http.client import KlyrekHTTPClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _echo_transport() -> httpx.MockTransport:
|
|
11
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
12
|
+
return httpx.Response(
|
|
13
|
+
200,
|
|
14
|
+
json={"path": request.url.path, "ua": request.headers.get("user-agent")},
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
return httpx.MockTransport(handler)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_request_within_scope_succeeds():
|
|
21
|
+
scope = AuthorizationScope(authorized_hosts=["target.com"])
|
|
22
|
+
config = KlyrekConfig(rate_limit_per_host=0)
|
|
23
|
+
with KlyrekHTTPClient(scope, config=config, transport=_echo_transport()) as client:
|
|
24
|
+
response = client.get("https://target.com/login")
|
|
25
|
+
assert response.status_code == 200
|
|
26
|
+
assert response.json()["path"] == "/login"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_request_out_of_scope_raises():
|
|
30
|
+
scope = AuthorizationScope(authorized_hosts=["target.com"])
|
|
31
|
+
with KlyrekHTTPClient(scope, transport=_echo_transport()) as client, pytest.raises(
|
|
32
|
+
ScopeViolationError
|
|
33
|
+
):
|
|
34
|
+
client.get("https://evil.com")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_user_agent_applied_from_config():
|
|
38
|
+
scope = AuthorizationScope(authorized_hosts=["target.com"])
|
|
39
|
+
config = KlyrekConfig(user_agent="Klyrek-Test/1.0", rate_limit_per_host=0)
|
|
40
|
+
with KlyrekHTTPClient(scope, config=config, transport=_echo_transport()) as client:
|
|
41
|
+
response = client.get("https://target.com/")
|
|
42
|
+
assert response.json()["ua"] == "Klyrek-Test/1.0"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_verbs_dispatch_correct_http_method():
|
|
46
|
+
scope = AuthorizationScope(authorized_hosts=["target.com"])
|
|
47
|
+
seen_methods: list[str] = []
|
|
48
|
+
|
|
49
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
50
|
+
seen_methods.append(request.method)
|
|
51
|
+
return httpx.Response(200)
|
|
52
|
+
|
|
53
|
+
config = KlyrekConfig(rate_limit_per_host=0)
|
|
54
|
+
with KlyrekHTTPClient(scope, config=config, transport=httpx.MockTransport(handler)) as client:
|
|
55
|
+
client.get("https://target.com/")
|
|
56
|
+
client.post("https://target.com/")
|
|
57
|
+
client.put("https://target.com/")
|
|
58
|
+
client.head("https://target.com/")
|
|
59
|
+
client.delete("https://target.com/")
|
|
60
|
+
|
|
61
|
+
assert seen_methods == ["GET", "POST", "PUT", "HEAD", "DELETE"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_scope_violation_short_circuits_before_request():
|
|
65
|
+
scope = AuthorizationScope(authorized_hosts=["target.com"])
|
|
66
|
+
called = False
|
|
67
|
+
|
|
68
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
69
|
+
nonlocal called
|
|
70
|
+
called = True
|
|
71
|
+
return httpx.Response(200)
|
|
72
|
+
|
|
73
|
+
with KlyrekHTTPClient(scope, transport=httpx.MockTransport(handler)) as client, pytest.raises(
|
|
74
|
+
ScopeViolationError
|
|
75
|
+
):
|
|
76
|
+
client.get("https://evil.com")
|
|
77
|
+
|
|
78
|
+
assert called is False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_redirect_to_out_of_scope_host_is_blocked():
|
|
82
|
+
scope = AuthorizationScope(authorized_hosts=["target.com"])
|
|
83
|
+
config = KlyrekConfig(rate_limit_per_host=0)
|
|
84
|
+
|
|
85
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
86
|
+
if request.url.host == "target.com":
|
|
87
|
+
return httpx.Response(302, headers={"Location": "https://evil.com/steal"})
|
|
88
|
+
return httpx.Response(200) # pragma: no cover - should never be reached
|
|
89
|
+
|
|
90
|
+
with KlyrekHTTPClient(
|
|
91
|
+
scope, config=config, transport=httpx.MockTransport(handler)
|
|
92
|
+
) as client, pytest.raises(ScopeViolationError):
|
|
93
|
+
client.get("https://target.com/login")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_redirect_within_scope_is_followed():
|
|
97
|
+
scope = AuthorizationScope(authorized_hosts=["target.com"])
|
|
98
|
+
config = KlyrekConfig(rate_limit_per_host=0)
|
|
99
|
+
|
|
100
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
101
|
+
if request.url.path == "/old":
|
|
102
|
+
return httpx.Response(302, headers={"Location": "https://target.com/new"})
|
|
103
|
+
return httpx.Response(200, json={"path": request.url.path})
|
|
104
|
+
|
|
105
|
+
with KlyrekHTTPClient(scope, config=config, transport=httpx.MockTransport(handler)) as client:
|
|
106
|
+
response = client.get("https://target.com/old")
|
|
107
|
+
|
|
108
|
+
assert response.json()["path"] == "/new"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from klyrek_http.rate_limit import RateLimiter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeClock:
|
|
7
|
+
"""A controllable clock where sleep() advances time instead of blocking."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, start: float = 0.0) -> None:
|
|
10
|
+
self.t = start
|
|
11
|
+
self.sleep_calls: list[float] = []
|
|
12
|
+
|
|
13
|
+
def now(self) -> float:
|
|
14
|
+
return self.t
|
|
15
|
+
|
|
16
|
+
def tick(self, seconds: float) -> None:
|
|
17
|
+
self.t += seconds
|
|
18
|
+
|
|
19
|
+
def advance(self, seconds: float) -> None:
|
|
20
|
+
self.sleep_calls.append(seconds)
|
|
21
|
+
self.t += seconds
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _limiter(min_interval: float, clock: FakeClock) -> RateLimiter:
|
|
25
|
+
return RateLimiter(min_interval=min_interval, clock=clock.now, sleep=clock.advance)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_no_wait_on_first_request():
|
|
29
|
+
clock = FakeClock()
|
|
30
|
+
limiter = _limiter(1.0, clock)
|
|
31
|
+
limiter.wait("target.com")
|
|
32
|
+
assert clock.sleep_calls == []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_waits_remaining_interval():
|
|
36
|
+
clock = FakeClock()
|
|
37
|
+
limiter = _limiter(1.0, clock)
|
|
38
|
+
limiter.wait("target.com")
|
|
39
|
+
clock.tick(0.4)
|
|
40
|
+
limiter.wait("target.com")
|
|
41
|
+
assert clock.sleep_calls == [pytest.approx(0.6)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_no_wait_when_interval_already_elapsed():
|
|
45
|
+
clock = FakeClock()
|
|
46
|
+
limiter = _limiter(1.0, clock)
|
|
47
|
+
limiter.wait("target.com")
|
|
48
|
+
clock.tick(1.5)
|
|
49
|
+
limiter.wait("target.com")
|
|
50
|
+
assert clock.sleep_calls == []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_hosts_tracked_independently():
|
|
54
|
+
clock = FakeClock()
|
|
55
|
+
limiter = _limiter(1.0, clock)
|
|
56
|
+
limiter.wait("a.com")
|
|
57
|
+
clock.tick(0.1)
|
|
58
|
+
limiter.wait("b.com")
|
|
59
|
+
assert clock.sleep_calls == []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_zero_interval_never_waits():
|
|
63
|
+
clock = FakeClock()
|
|
64
|
+
limiter = _limiter(0.0, clock)
|
|
65
|
+
limiter.wait("target.com")
|
|
66
|
+
limiter.wait("target.com")
|
|
67
|
+
assert clock.sleep_calls == []
|