ratelimitly 2.0.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.
- ratelimitly-2.0.0/PKG-INFO +165 -0
- ratelimitly-2.0.0/README.md +147 -0
- ratelimitly-2.0.0/pyproject.toml +32 -0
- ratelimitly-2.0.0/setup.cfg +4 -0
- ratelimitly-2.0.0/setup.py +6 -0
- ratelimitly-2.0.0/src/ratelimitly/__init__.py +63 -0
- ratelimitly-2.0.0/src/ratelimitly/auth.py +165 -0
- ratelimitly-2.0.0/src/ratelimitly/client.py +514 -0
- ratelimitly-2.0.0/src/ratelimitly/discovery.py +224 -0
- ratelimitly-2.0.0/src/ratelimitly/policy.py +149 -0
- ratelimitly-2.0.0/src/ratelimitly/protocol.py +398 -0
- ratelimitly-2.0.0/src/ratelimitly/steering.py +65 -0
- ratelimitly-2.0.0/src/ratelimitly/types.py +72 -0
- ratelimitly-2.0.0/src/ratelimitly.egg-info/PKG-INFO +165 -0
- ratelimitly-2.0.0/src/ratelimitly.egg-info/SOURCES.txt +23 -0
- ratelimitly-2.0.0/src/ratelimitly.egg-info/dependency_links.txt +1 -0
- ratelimitly-2.0.0/src/ratelimitly.egg-info/requires.txt +6 -0
- ratelimitly-2.0.0/src/ratelimitly.egg-info/top_level.txt +1 -0
- ratelimitly-2.0.0/tests/test_auth.py +67 -0
- ratelimitly-2.0.0/tests/test_c_reference.py +223 -0
- ratelimitly-2.0.0/tests/test_client.py +417 -0
- ratelimitly-2.0.0/tests/test_discovery.py +92 -0
- ratelimitly-2.0.0/tests/test_policy.py +64 -0
- ratelimitly-2.0.0/tests/test_protocol.py +170 -0
- ratelimitly-2.0.0/tests/test_steering.py +119 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ratelimitly
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Official Python client library for RateLimitly high-performance rate limiting.
|
|
5
|
+
Author-email: RateLimitly Team <support@ratelimitly.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://ratelimitly.com
|
|
8
|
+
Project-URL: Repository, https://github.com/ratelimitly-com/rl-python-client
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: cryptography>=3.4
|
|
14
|
+
Requires-Dist: dnspython>=2.0.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.20.0; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# RateLimitly Python client
|
|
20
|
+
|
|
21
|
+
`ratelimitly` is the official Python client for [RateLimitly](https://ratelimitly.com/), a distributed admission-control service. An application asks whether it may begin work that consumes rate-limited resources. The decision can also depend on recent latency observations for services used by that work.
|
|
22
|
+
|
|
23
|
+
The library exposes two independent operations:
|
|
24
|
+
|
|
25
|
+
- A **resource request** atomically asks to consume quantities from zero or more rate buckets, subject to zero or more latency guards. A successful decision represents consumption of every requested quantity and authorizes the work to proceed.
|
|
26
|
+
- A **latency report** contributes measured service latencies to one or more trackers. It does not request resources or make an admission decision.
|
|
27
|
+
|
|
28
|
+
An empty resource request succeeds locally. A guard-only request is sent to RateLimitly and evaluated normally.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install ratelimitly
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Request one token
|
|
37
|
+
|
|
38
|
+
In English, this request means: “Give me one token from the `checkout` bucket whose definition is 100 tokens per 1,000 ms.”
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from ratelimitly import (
|
|
42
|
+
RCLIENT_OK,
|
|
43
|
+
RateLimitlyClient,
|
|
44
|
+
ResourceRequest,
|
|
45
|
+
r_client_derive_bucket_id,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
window_size_ms = 1_000
|
|
49
|
+
rate_limit = 100
|
|
50
|
+
|
|
51
|
+
resource = ResourceRequest(
|
|
52
|
+
bucket_id=r_client_derive_bucket_id(
|
|
53
|
+
"checkout", # Exact application-defined bucket name.
|
|
54
|
+
window_size_ms, # Bucket window in milliseconds.
|
|
55
|
+
rate_limit, # Tokens available per window.
|
|
56
|
+
),
|
|
57
|
+
window_size_ms=window_size_ms,
|
|
58
|
+
rate_limit=rate_limit,
|
|
59
|
+
tokens_requested=1,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
with RateLimitlyClient("rl-aes1...") as client:
|
|
63
|
+
status, result = client.check_rate_limit([resource])
|
|
64
|
+
|
|
65
|
+
if status != RCLIENT_OK:
|
|
66
|
+
print(f"No decision: client status {status}")
|
|
67
|
+
elif result.success:
|
|
68
|
+
print("Granted; perform the protected work")
|
|
69
|
+
else:
|
|
70
|
+
print("Denied; do not perform the protected work")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`RCLIENT_OK` means a valid RateLimitly decision was received; it does not mean the request was granted. Check `result.success` for the admission decision.
|
|
74
|
+
|
|
75
|
+
## Report one measured latency
|
|
76
|
+
|
|
77
|
+
In English: “Add a 25 ms observation to the `inventory-backend` tracker defined by these storage settings.”
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from ratelimitly import (
|
|
81
|
+
RateLimitlyClient,
|
|
82
|
+
ServiceLatencyReport,
|
|
83
|
+
r_client_derive_latency_tracker_id,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
ttl_ms = 10_000
|
|
87
|
+
max_samples = 100
|
|
88
|
+
min_sample_threshold = 5
|
|
89
|
+
|
|
90
|
+
tracker_id = r_client_derive_latency_tracker_id(
|
|
91
|
+
"inventory-backend", # Exact application-defined tracker name.
|
|
92
|
+
ttl_ms, # Maximum sample lifetime.
|
|
93
|
+
max_samples, # Samples considered by the tracker.
|
|
94
|
+
min_sample_threshold, # Warm-up samples before guards take effect.
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
report = ServiceLatencyReport(
|
|
98
|
+
latency_tracker_id=tracker_id,
|
|
99
|
+
observed_latency_ms=25,
|
|
100
|
+
ttl_ms=ttl_ms,
|
|
101
|
+
max_samples=max_samples,
|
|
102
|
+
min_sample_threshold=min_sample_threshold,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
with RateLimitlyClient("rl-aes1...") as client:
|
|
106
|
+
status = client.report_latency([report])
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Measure the service operation, not the RateLimitly request. Reports are independent of resource requests and may be sent by a different process.
|
|
110
|
+
|
|
111
|
+
## Add a latency guard
|
|
112
|
+
|
|
113
|
+
This request asks for the same token only when the tracker’s current latency is below 50 ms:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from ratelimitly import LatencyGuard
|
|
117
|
+
|
|
118
|
+
guard = LatencyGuard(
|
|
119
|
+
latency_tracker_id=tracker_id, # Same tracker definition as the report.
|
|
120
|
+
threshold_ms=50, # Admission requires current latency < 50 ms.
|
|
121
|
+
ttl_ms=ttl_ms,
|
|
122
|
+
max_samples=max_samples,
|
|
123
|
+
min_sample_threshold=min_sample_threshold,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
with RateLimitlyClient("rl-aes1...") as client:
|
|
127
|
+
status, result = client.check_rate_limit(
|
|
128
|
+
resources=[resource],
|
|
129
|
+
guards=[guard],
|
|
130
|
+
)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Canonical IDs must agree across clients
|
|
134
|
+
|
|
135
|
+
Bucket IDs include the exact bucket-name bytes, `window_size_ms`, and `rate_limit`. Latency-tracker IDs include the exact tracker-name bytes, `ttl_ms`, `max_samples`, and `min_sample_threshold`; a guard threshold is deliberately not part of the tracker ID.
|
|
136
|
+
|
|
137
|
+
The Python helpers implement the same domain-separated binary preimage,
|
|
138
|
+
little-endian integer encoding, BLAKE2s-256 digest, and 16-byte truncation as
|
|
139
|
+
the coordinated wire-v2 C client. Do not replace them with hashing of formatted
|
|
140
|
+
text, and do not use `hashlib.blake2s(..., digest_size=16)`: digest length is a
|
|
141
|
+
BLAKE2 parameter, so that produces a different ID.
|
|
142
|
+
|
|
143
|
+
See [API reference](docs/api.md#canonical-content-defined-identifiers) for the exact formula and cross-client known-answer vectors.
|
|
144
|
+
|
|
145
|
+
## High-availability policy
|
|
146
|
+
|
|
147
|
+
The default policy matches `rl-c-client`: `unit_ms=20`, one replay, a fixed one-unit round duration, one final receive-only unit, and completion delivery enabled. Its deduplication TTL and maximum decision horizon are 60 ms. The initial transmission goes to every discovered r-server, and the oldest known server’s response wins when it arrives within the first round.
|
|
148
|
+
|
|
149
|
+
See [configuration](docs/configuration.md) for the complete parametrized policy and [architecture](docs/architecture.md) for wire and selection semantics.
|
|
150
|
+
|
|
151
|
+
## API layers
|
|
152
|
+
|
|
153
|
+
`RateLimitlyClient` is blocking. `AsyncRateLimitlyClient` provides the same serialized state machine through `asyncio`. A client preserves DNS results and UDP sockets across calls; call `close()` or use the context-manager forms to release them.
|
|
154
|
+
|
|
155
|
+
The client returns the same status-code family as the C library. It does not choose an application fail-open or fail-closed policy: the caller decides what to do when no RateLimitly decision is available.
|
|
156
|
+
|
|
157
|
+
## Documentation
|
|
158
|
+
|
|
159
|
+
- [API reference](docs/api.md)
|
|
160
|
+
- [Configuration and request policy](docs/configuration.md)
|
|
161
|
+
- [Architecture, wire format, and conformance](docs/architecture.md)
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# RateLimitly Python client
|
|
2
|
+
|
|
3
|
+
`ratelimitly` is the official Python client for [RateLimitly](https://ratelimitly.com/), a distributed admission-control service. An application asks whether it may begin work that consumes rate-limited resources. The decision can also depend on recent latency observations for services used by that work.
|
|
4
|
+
|
|
5
|
+
The library exposes two independent operations:
|
|
6
|
+
|
|
7
|
+
- A **resource request** atomically asks to consume quantities from zero or more rate buckets, subject to zero or more latency guards. A successful decision represents consumption of every requested quantity and authorizes the work to proceed.
|
|
8
|
+
- A **latency report** contributes measured service latencies to one or more trackers. It does not request resources or make an admission decision.
|
|
9
|
+
|
|
10
|
+
An empty resource request succeeds locally. A guard-only request is sent to RateLimitly and evaluated normally.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install ratelimitly
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Request one token
|
|
19
|
+
|
|
20
|
+
In English, this request means: “Give me one token from the `checkout` bucket whose definition is 100 tokens per 1,000 ms.”
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from ratelimitly import (
|
|
24
|
+
RCLIENT_OK,
|
|
25
|
+
RateLimitlyClient,
|
|
26
|
+
ResourceRequest,
|
|
27
|
+
r_client_derive_bucket_id,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
window_size_ms = 1_000
|
|
31
|
+
rate_limit = 100
|
|
32
|
+
|
|
33
|
+
resource = ResourceRequest(
|
|
34
|
+
bucket_id=r_client_derive_bucket_id(
|
|
35
|
+
"checkout", # Exact application-defined bucket name.
|
|
36
|
+
window_size_ms, # Bucket window in milliseconds.
|
|
37
|
+
rate_limit, # Tokens available per window.
|
|
38
|
+
),
|
|
39
|
+
window_size_ms=window_size_ms,
|
|
40
|
+
rate_limit=rate_limit,
|
|
41
|
+
tokens_requested=1,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
with RateLimitlyClient("rl-aes1...") as client:
|
|
45
|
+
status, result = client.check_rate_limit([resource])
|
|
46
|
+
|
|
47
|
+
if status != RCLIENT_OK:
|
|
48
|
+
print(f"No decision: client status {status}")
|
|
49
|
+
elif result.success:
|
|
50
|
+
print("Granted; perform the protected work")
|
|
51
|
+
else:
|
|
52
|
+
print("Denied; do not perform the protected work")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`RCLIENT_OK` means a valid RateLimitly decision was received; it does not mean the request was granted. Check `result.success` for the admission decision.
|
|
56
|
+
|
|
57
|
+
## Report one measured latency
|
|
58
|
+
|
|
59
|
+
In English: “Add a 25 ms observation to the `inventory-backend` tracker defined by these storage settings.”
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from ratelimitly import (
|
|
63
|
+
RateLimitlyClient,
|
|
64
|
+
ServiceLatencyReport,
|
|
65
|
+
r_client_derive_latency_tracker_id,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
ttl_ms = 10_000
|
|
69
|
+
max_samples = 100
|
|
70
|
+
min_sample_threshold = 5
|
|
71
|
+
|
|
72
|
+
tracker_id = r_client_derive_latency_tracker_id(
|
|
73
|
+
"inventory-backend", # Exact application-defined tracker name.
|
|
74
|
+
ttl_ms, # Maximum sample lifetime.
|
|
75
|
+
max_samples, # Samples considered by the tracker.
|
|
76
|
+
min_sample_threshold, # Warm-up samples before guards take effect.
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
report = ServiceLatencyReport(
|
|
80
|
+
latency_tracker_id=tracker_id,
|
|
81
|
+
observed_latency_ms=25,
|
|
82
|
+
ttl_ms=ttl_ms,
|
|
83
|
+
max_samples=max_samples,
|
|
84
|
+
min_sample_threshold=min_sample_threshold,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
with RateLimitlyClient("rl-aes1...") as client:
|
|
88
|
+
status = client.report_latency([report])
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Measure the service operation, not the RateLimitly request. Reports are independent of resource requests and may be sent by a different process.
|
|
92
|
+
|
|
93
|
+
## Add a latency guard
|
|
94
|
+
|
|
95
|
+
This request asks for the same token only when the tracker’s current latency is below 50 ms:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from ratelimitly import LatencyGuard
|
|
99
|
+
|
|
100
|
+
guard = LatencyGuard(
|
|
101
|
+
latency_tracker_id=tracker_id, # Same tracker definition as the report.
|
|
102
|
+
threshold_ms=50, # Admission requires current latency < 50 ms.
|
|
103
|
+
ttl_ms=ttl_ms,
|
|
104
|
+
max_samples=max_samples,
|
|
105
|
+
min_sample_threshold=min_sample_threshold,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
with RateLimitlyClient("rl-aes1...") as client:
|
|
109
|
+
status, result = client.check_rate_limit(
|
|
110
|
+
resources=[resource],
|
|
111
|
+
guards=[guard],
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Canonical IDs must agree across clients
|
|
116
|
+
|
|
117
|
+
Bucket IDs include the exact bucket-name bytes, `window_size_ms`, and `rate_limit`. Latency-tracker IDs include the exact tracker-name bytes, `ttl_ms`, `max_samples`, and `min_sample_threshold`; a guard threshold is deliberately not part of the tracker ID.
|
|
118
|
+
|
|
119
|
+
The Python helpers implement the same domain-separated binary preimage,
|
|
120
|
+
little-endian integer encoding, BLAKE2s-256 digest, and 16-byte truncation as
|
|
121
|
+
the coordinated wire-v2 C client. Do not replace them with hashing of formatted
|
|
122
|
+
text, and do not use `hashlib.blake2s(..., digest_size=16)`: digest length is a
|
|
123
|
+
BLAKE2 parameter, so that produces a different ID.
|
|
124
|
+
|
|
125
|
+
See [API reference](docs/api.md#canonical-content-defined-identifiers) for the exact formula and cross-client known-answer vectors.
|
|
126
|
+
|
|
127
|
+
## High-availability policy
|
|
128
|
+
|
|
129
|
+
The default policy matches `rl-c-client`: `unit_ms=20`, one replay, a fixed one-unit round duration, one final receive-only unit, and completion delivery enabled. Its deduplication TTL and maximum decision horizon are 60 ms. The initial transmission goes to every discovered r-server, and the oldest known server’s response wins when it arrives within the first round.
|
|
130
|
+
|
|
131
|
+
See [configuration](docs/configuration.md) for the complete parametrized policy and [architecture](docs/architecture.md) for wire and selection semantics.
|
|
132
|
+
|
|
133
|
+
## API layers
|
|
134
|
+
|
|
135
|
+
`RateLimitlyClient` is blocking. `AsyncRateLimitlyClient` provides the same serialized state machine through `asyncio`. A client preserves DNS results and UDP sockets across calls; call `close()` or use the context-manager forms to release them.
|
|
136
|
+
|
|
137
|
+
The client returns the same status-code family as the C library. It does not choose an application fail-open or fail-closed policy: the caller decides what to do when no RateLimitly decision is available.
|
|
138
|
+
|
|
139
|
+
## Documentation
|
|
140
|
+
|
|
141
|
+
- [API reference](docs/api.md)
|
|
142
|
+
- [Configuration and request policy](docs/configuration.md)
|
|
143
|
+
- [Architecture, wire format, and conformance](docs/architecture.md)
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ratelimitly"
|
|
7
|
+
version = "2.0.0"
|
|
8
|
+
description = "Official Python client library for RateLimitly high-performance rate limiting."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "RateLimitly Team", email = "support@ratelimitly.com" }
|
|
12
|
+
]
|
|
13
|
+
license = { text = "MIT" }
|
|
14
|
+
requires-python = ">=3.8"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"cryptography>=3.4",
|
|
17
|
+
"dnspython>=2.0.0",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://ratelimitly.com"
|
|
26
|
+
Repository = "https://github.com/ratelimitly-com/rl-python-client"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=7.0.0",
|
|
31
|
+
"pytest-asyncio>=0.20.0",
|
|
32
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""RateLimitly low-level Python client library."""
|
|
2
|
+
|
|
3
|
+
from .auth import parse_auth_key, AuthKeyInfo
|
|
4
|
+
from .policy import (
|
|
5
|
+
RequestPolicy,
|
|
6
|
+
Schedule,
|
|
7
|
+
FixedSchedule,
|
|
8
|
+
LinearSchedule,
|
|
9
|
+
ExponentialSchedule,
|
|
10
|
+
default_request_policy,
|
|
11
|
+
)
|
|
12
|
+
from .types import (
|
|
13
|
+
ResourceRequest,
|
|
14
|
+
LatencyGuard,
|
|
15
|
+
ServiceLatencyReport,
|
|
16
|
+
GuardResult,
|
|
17
|
+
ResourceResult,
|
|
18
|
+
RateLimitResult,
|
|
19
|
+
RCLIENT_OK,
|
|
20
|
+
RCLIENT_ERR_IO,
|
|
21
|
+
RCLIENT_ERR_TIMEOUT,
|
|
22
|
+
RCLIENT_ERR_PROTOCOL,
|
|
23
|
+
RCLIENT_ERR_AUTH,
|
|
24
|
+
RCLIENT_ERR_DNS,
|
|
25
|
+
RCLIENT_ERR_CONFIG,
|
|
26
|
+
RCLIENT_ERR_NOMEM,
|
|
27
|
+
)
|
|
28
|
+
from .protocol import (
|
|
29
|
+
r_client_derive_bucket_id,
|
|
30
|
+
r_client_derive_latency_tracker_id,
|
|
31
|
+
)
|
|
32
|
+
from .client import RateLimitlyClient, AsyncRateLimitlyClient
|
|
33
|
+
|
|
34
|
+
__version__ = "2.0.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"RateLimitlyClient",
|
|
38
|
+
"AsyncRateLimitlyClient",
|
|
39
|
+
"ResourceRequest",
|
|
40
|
+
"LatencyGuard",
|
|
41
|
+
"ServiceLatencyReport",
|
|
42
|
+
"GuardResult",
|
|
43
|
+
"ResourceResult",
|
|
44
|
+
"RateLimitResult",
|
|
45
|
+
"RCLIENT_OK",
|
|
46
|
+
"RCLIENT_ERR_IO",
|
|
47
|
+
"RCLIENT_ERR_TIMEOUT",
|
|
48
|
+
"RCLIENT_ERR_PROTOCOL",
|
|
49
|
+
"RCLIENT_ERR_AUTH",
|
|
50
|
+
"RCLIENT_ERR_DNS",
|
|
51
|
+
"RCLIENT_ERR_CONFIG",
|
|
52
|
+
"RCLIENT_ERR_NOMEM",
|
|
53
|
+
"RequestPolicy",
|
|
54
|
+
"Schedule",
|
|
55
|
+
"FixedSchedule",
|
|
56
|
+
"LinearSchedule",
|
|
57
|
+
"ExponentialSchedule",
|
|
58
|
+
"default_request_policy",
|
|
59
|
+
"parse_auth_key",
|
|
60
|
+
"AuthKeyInfo",
|
|
61
|
+
"r_client_derive_bucket_id",
|
|
62
|
+
"r_client_derive_latency_tracker_id",
|
|
63
|
+
]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Bech32 authentication key parser for RateLimitly credentials."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import List, Literal, Tuple
|
|
5
|
+
|
|
6
|
+
BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
7
|
+
BECH32_REV = {c: i for i, c in enumerate(BECH32_CHARSET)}
|
|
8
|
+
TENANT_KEY_FORMAT_VERSION = 1
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class AuthKeyInfo:
|
|
13
|
+
auth_type: Literal["aes", "cookie"]
|
|
14
|
+
format_version: int
|
|
15
|
+
key_id: int
|
|
16
|
+
secret: bytes
|
|
17
|
+
rate_buckets_max: int
|
|
18
|
+
latency_services_max: int
|
|
19
|
+
metrics_labels_max: int
|
|
20
|
+
latency_buffer_size_max: int
|
|
21
|
+
dedup_ttl_ms_max: int
|
|
22
|
+
rate_window_size_ms_max: int
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def default_dns_srv(self) -> str:
|
|
26
|
+
"""Constructs default tenant SRV domain string: c-${key_id}.p0.ratelimitly.com"""
|
|
27
|
+
return f"c-{self.key_id}.p0.ratelimitly.com"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _bech32_polymod(values: List[int]) -> int:
|
|
31
|
+
generators = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
|
|
32
|
+
checksum = 1
|
|
33
|
+
for value in values:
|
|
34
|
+
top = checksum >> 25
|
|
35
|
+
checksum = ((checksum & 0x1FFFFFF) << 5) ^ value
|
|
36
|
+
for index, generator in enumerate(generators):
|
|
37
|
+
if (top >> index) & 1:
|
|
38
|
+
checksum ^= generator
|
|
39
|
+
return checksum
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _bech32_hrp_expand(hrp: str) -> List[int]:
|
|
43
|
+
return [ord(char) >> 5 for char in hrp] + [0] + [ord(char) & 31 for char in hrp]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _bech32_decode(bech_str: str) -> Tuple[str, List[int]]:
|
|
47
|
+
"""Decodes a Bech32 string into HRP and 5-bit data values."""
|
|
48
|
+
if not bech_str or any(ord(char) < 33 or ord(char) > 126 for char in bech_str):
|
|
49
|
+
raise ValueError("Invalid Bech32 length")
|
|
50
|
+
if bech_str.lower() != bech_str and bech_str.upper() != bech_str:
|
|
51
|
+
raise ValueError("Mixed case Bech32 string")
|
|
52
|
+
|
|
53
|
+
bech_str = bech_str.lower()
|
|
54
|
+
pos = bech_str.rfind("1")
|
|
55
|
+
if pos < 1 or pos + 7 > len(bech_str):
|
|
56
|
+
raise ValueError("Invalid separator position in Bech32 string")
|
|
57
|
+
|
|
58
|
+
hrp = bech_str[:pos]
|
|
59
|
+
data = []
|
|
60
|
+
for c in bech_str[pos + 1:]:
|
|
61
|
+
if c not in BECH32_REV:
|
|
62
|
+
raise ValueError(f"Invalid character in Bech32 string: '{c}'")
|
|
63
|
+
data.append(BECH32_REV[c])
|
|
64
|
+
|
|
65
|
+
if _bech32_polymod(_bech32_hrp_expand(hrp) + data) != 1:
|
|
66
|
+
raise ValueError("Invalid Bech32 checksum")
|
|
67
|
+
|
|
68
|
+
return hrp, data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _convertbits(data: List[int], frombits: int, tobits: int, pad: bool = True) -> bytes:
|
|
72
|
+
"""Converts a bit array from one representation to another (5-bit to 8-bit)."""
|
|
73
|
+
acc = 0
|
|
74
|
+
bits = 0
|
|
75
|
+
ret = bytearray()
|
|
76
|
+
maxv = (1 << tobits) - 1
|
|
77
|
+
max_acc = (1 << (frombits + tobits - 1)) - 1
|
|
78
|
+
for value in data:
|
|
79
|
+
if value < 0 or (value >> frombits):
|
|
80
|
+
raise ValueError("Invalid bit value")
|
|
81
|
+
acc = ((acc << frombits) | value) & max_acc
|
|
82
|
+
bits += frombits
|
|
83
|
+
while bits >= tobits:
|
|
84
|
+
bits -= tobits
|
|
85
|
+
ret.append((acc >> bits) & maxv)
|
|
86
|
+
if pad:
|
|
87
|
+
if bits:
|
|
88
|
+
ret.append((acc << (tobits - bits)) & maxv)
|
|
89
|
+
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
|
|
90
|
+
raise ValueError("Invalid padding in bit conversion")
|
|
91
|
+
return bytes(ret)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _decode_quota_word(word: int) -> Tuple[int, int, int, int, int, int]:
|
|
95
|
+
rate_exp = word & 0x1F
|
|
96
|
+
latency_exp = (word >> 5) & 0x1F
|
|
97
|
+
labels_exp = (word >> 10) & 0x1F
|
|
98
|
+
buffer_exp = (word >> 15) & 0x0F
|
|
99
|
+
dedup_units = (word >> 19) & 0xFF
|
|
100
|
+
window_exp = (word >> 27) & 0x1F
|
|
101
|
+
|
|
102
|
+
if rate_exp > 24:
|
|
103
|
+
raise ValueError("Invalid packed rate_buckets_max quota")
|
|
104
|
+
if latency_exp > 24:
|
|
105
|
+
raise ValueError("Invalid packed latency_services_max quota")
|
|
106
|
+
if not 1 <= dedup_units <= 200:
|
|
107
|
+
raise ValueError("Invalid packed dedup_ttl_ms_max quota")
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
1 << rate_exp,
|
|
111
|
+
1 << latency_exp,
|
|
112
|
+
1 << labels_exp,
|
|
113
|
+
1 << buffer_exp,
|
|
114
|
+
dedup_units * 10,
|
|
115
|
+
0xFFFFFFFF if window_exp == 31 else 1 << window_exp,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_auth_key(key_str: str) -> AuthKeyInfo:
|
|
120
|
+
"""
|
|
121
|
+
Parses a RateLimitly Bech32 authentication key (rl-aes1... or rl-cookie1...).
|
|
122
|
+
|
|
123
|
+
Extracts:
|
|
124
|
+
- Auth Type ('aes' or 'cookie')
|
|
125
|
+
- Key ID (uint64)
|
|
126
|
+
- Secret payload bytes (32 bytes)
|
|
127
|
+
"""
|
|
128
|
+
if not isinstance(key_str, str):
|
|
129
|
+
raise TypeError("Authentication key must be a string")
|
|
130
|
+
|
|
131
|
+
hrp, data_5bit = _bech32_decode(key_str)
|
|
132
|
+
if hrp == "rl-aes":
|
|
133
|
+
auth_type = "aes"
|
|
134
|
+
elif hrp == "rl-cookie":
|
|
135
|
+
auth_type = "cookie"
|
|
136
|
+
else:
|
|
137
|
+
raise ValueError("Invalid auth key HRP; expected 'rl-aes' or 'rl-cookie'")
|
|
138
|
+
|
|
139
|
+
# Remove 6-character checksum at the end
|
|
140
|
+
payload_5bit = data_5bit[:-6]
|
|
141
|
+
raw_bytes = _convertbits(payload_5bit, 5, 8, pad=False)
|
|
142
|
+
|
|
143
|
+
if len(raw_bytes) != 45:
|
|
144
|
+
raise ValueError(f"Invalid auth key payload length: {len(raw_bytes)} bytes (expected 45)")
|
|
145
|
+
|
|
146
|
+
format_version = raw_bytes[0]
|
|
147
|
+
if format_version != TENANT_KEY_FORMAT_VERSION:
|
|
148
|
+
raise ValueError(f"Unsupported auth key format version: {format_version}")
|
|
149
|
+
|
|
150
|
+
key_id = int.from_bytes(raw_bytes[1:9], byteorder="little")
|
|
151
|
+
secret = raw_bytes[9:41]
|
|
152
|
+
quotas = _decode_quota_word(int.from_bytes(raw_bytes[41:45], byteorder="little"))
|
|
153
|
+
|
|
154
|
+
return AuthKeyInfo(
|
|
155
|
+
auth_type=auth_type,
|
|
156
|
+
format_version=format_version,
|
|
157
|
+
key_id=key_id,
|
|
158
|
+
secret=secret,
|
|
159
|
+
rate_buckets_max=quotas[0],
|
|
160
|
+
latency_services_max=quotas[1],
|
|
161
|
+
metrics_labels_max=quotas[2],
|
|
162
|
+
latency_buffer_size_max=quotas[3],
|
|
163
|
+
dedup_ttl_ms_max=quotas[4],
|
|
164
|
+
rate_window_size_ms_max=quotas[5],
|
|
165
|
+
)
|