detent-sdk 0.1.0__py3-none-any.whl
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.
- detent/__init__.py +35 -0
- detent/_config.py +8 -0
- detent/_core.py +127 -0
- detent/async_client.py +128 -0
- detent/client.py +128 -0
- detent/errors.py +31 -0
- detent/models.py +54 -0
- detent/py.typed +0 -0
- detent_sdk-0.1.0.dist-info/METADATA +71 -0
- detent_sdk-0.1.0.dist-info/RECORD +12 -0
- detent_sdk-0.1.0.dist-info/WHEEL +4 -0
- detent_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
detent/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Typed Python client for the Detent rate-limiting API."""
|
|
2
|
+
|
|
3
|
+
from .async_client import AsyncDetent
|
|
4
|
+
from .client import Detent
|
|
5
|
+
from .errors import (
|
|
6
|
+
DetentAPIError,
|
|
7
|
+
DetentError,
|
|
8
|
+
DetentLeaseDenied,
|
|
9
|
+
DetentTransportError,
|
|
10
|
+
)
|
|
11
|
+
from .models import (
|
|
12
|
+
AcquireResult,
|
|
13
|
+
Algorithm,
|
|
14
|
+
DayStat,
|
|
15
|
+
LimitResult,
|
|
16
|
+
MonthSummary,
|
|
17
|
+
ReleaseResult,
|
|
18
|
+
StatsResult,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Detent",
|
|
23
|
+
"AsyncDetent",
|
|
24
|
+
"DetentError",
|
|
25
|
+
"DetentAPIError",
|
|
26
|
+
"DetentTransportError",
|
|
27
|
+
"DetentLeaseDenied",
|
|
28
|
+
"LimitResult",
|
|
29
|
+
"AcquireResult",
|
|
30
|
+
"ReleaseResult",
|
|
31
|
+
"StatsResult",
|
|
32
|
+
"DayStat",
|
|
33
|
+
"MonthSummary",
|
|
34
|
+
"Algorithm",
|
|
35
|
+
]
|
detent/_config.py
ADDED
detent/_core.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from ._config import FailMode
|
|
8
|
+
from .errors import DetentAPIError, DetentError, DetentTransportError
|
|
9
|
+
from .models import (
|
|
10
|
+
AcquireResult,
|
|
11
|
+
DayStat,
|
|
12
|
+
LimitResult,
|
|
13
|
+
MonthSummary,
|
|
14
|
+
ReleaseResult,
|
|
15
|
+
StatsResult,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
LIMIT_PATH = "/v1/limit"
|
|
19
|
+
LEASES_PATH = "/v1/leases"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def limit_body(
|
|
23
|
+
namespace: str,
|
|
24
|
+
key: str,
|
|
25
|
+
algorithm: str | None,
|
|
26
|
+
limit: int | None,
|
|
27
|
+
window_ms: int | None,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
body: dict[str, Any] = {"namespace": namespace, "key": key}
|
|
30
|
+
if algorithm is not None:
|
|
31
|
+
body["algorithm"] = algorithm
|
|
32
|
+
if limit is not None:
|
|
33
|
+
body["limit"] = limit
|
|
34
|
+
if window_ms is not None:
|
|
35
|
+
body["window_ms"] = window_ms
|
|
36
|
+
return body
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def acquire_body(
|
|
40
|
+
namespace: str, key: str, limit: int | None, window_ms: int | None
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
body: dict[str, Any] = {"namespace": namespace, "key": key}
|
|
43
|
+
if limit is not None:
|
|
44
|
+
body["limit"] = limit
|
|
45
|
+
if window_ms is not None:
|
|
46
|
+
body["window_ms"] = window_ms
|
|
47
|
+
return body
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def stats_path(namespace: str) -> str:
|
|
51
|
+
return f"/v1/namespaces/{quote(namespace, safe='')}/stats"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def release_path(lease_id: str) -> str:
|
|
55
|
+
return f"/v1/leases/{quote(lease_id, safe='')}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_limit(data: dict[str, Any]) -> LimitResult:
|
|
59
|
+
return LimitResult(
|
|
60
|
+
allowed=data["allowed"],
|
|
61
|
+
remaining=data["remaining"],
|
|
62
|
+
reset_ms=data["reset_ms"],
|
|
63
|
+
limit=data["limit"],
|
|
64
|
+
degraded=False,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def degraded_limit(fail_mode: FailMode, limit: int | None) -> LimitResult:
|
|
69
|
+
return LimitResult(
|
|
70
|
+
allowed=(fail_mode == "open"),
|
|
71
|
+
remaining=0,
|
|
72
|
+
reset_ms=0,
|
|
73
|
+
limit=limit or 0,
|
|
74
|
+
degraded=True,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_acquire(data: dict[str, Any]) -> AcquireResult:
|
|
79
|
+
return AcquireResult(
|
|
80
|
+
allowed=data["allowed"],
|
|
81
|
+
lease_id=data.get("lease_id"),
|
|
82
|
+
active=data["active"],
|
|
83
|
+
limit=data["limit"],
|
|
84
|
+
reset_ms=data["reset_ms"],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_release(data: dict[str, Any]) -> ReleaseResult:
|
|
89
|
+
return ReleaseResult(released=data["released"], active=data["active"])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_stats(data: dict[str, Any]) -> StatsResult:
|
|
93
|
+
month = data["month"]
|
|
94
|
+
return StatsResult(
|
|
95
|
+
namespace=data["namespace"],
|
|
96
|
+
total=data["total"],
|
|
97
|
+
blocked=data["blocked"],
|
|
98
|
+
days=[DayStat(day=d["day"], total=d["total"], blocked=d["blocked"]) for d in data["days"]],
|
|
99
|
+
month=MonthSummary(
|
|
100
|
+
month=month["month"],
|
|
101
|
+
total=month["total"],
|
|
102
|
+
quota=month["quota"],
|
|
103
|
+
over_quota=month["over_quota"],
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def error_body(status: int, text: str) -> dict[str, str]:
|
|
109
|
+
try:
|
|
110
|
+
parsed = json.loads(text)
|
|
111
|
+
if isinstance(parsed, dict) and "error" in parsed:
|
|
112
|
+
return {"error": str(parsed["error"])}
|
|
113
|
+
except ValueError:
|
|
114
|
+
pass
|
|
115
|
+
return {"error": f"HTTP {status}"}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def api_error(status: int, body: dict[str, str]) -> DetentAPIError:
|
|
119
|
+
return DetentAPIError(status, body)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def should_degrade(err: DetentError) -> bool:
|
|
123
|
+
if isinstance(err, DetentTransportError):
|
|
124
|
+
return True
|
|
125
|
+
if isinstance(err, DetentAPIError):
|
|
126
|
+
return err.status >= 500
|
|
127
|
+
return False
|
detent/async_client.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Callable
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from . import _core
|
|
11
|
+
from ._config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, FailMode
|
|
12
|
+
from .errors import DetentError, DetentLeaseDenied, DetentTransportError
|
|
13
|
+
from .models import AcquireResult, Algorithm, LimitResult, ReleaseResult, StatsResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncDetent:
|
|
17
|
+
"""Asynchronous Detent client."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
api_key: str,
|
|
22
|
+
*,
|
|
23
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
24
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
25
|
+
fail_mode: FailMode = "open",
|
|
26
|
+
on_error: Callable[[DetentError], None] | None = None,
|
|
27
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
if not api_key:
|
|
30
|
+
raise ValueError("api_key is required")
|
|
31
|
+
self.fail_mode: FailMode = fail_mode
|
|
32
|
+
self.on_error = on_error
|
|
33
|
+
self._client = httpx.AsyncClient(
|
|
34
|
+
base_url=base_url.rstrip("/"),
|
|
35
|
+
timeout=timeout,
|
|
36
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
37
|
+
transport=transport,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async def _request(
|
|
41
|
+
self, method: str, path: str, body: dict[str, Any] | None = None
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
try:
|
|
44
|
+
resp = await self._client.request(method, path, json=body)
|
|
45
|
+
except httpx.TransportError as exc:
|
|
46
|
+
raise DetentTransportError("Detent request failed") from exc
|
|
47
|
+
if resp.is_success:
|
|
48
|
+
data: dict[str, Any] = resp.json()
|
|
49
|
+
return data
|
|
50
|
+
raise _core.api_error(resp.status_code, _core.error_body(resp.status_code, resp.text))
|
|
51
|
+
|
|
52
|
+
async def limit(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
namespace: str,
|
|
56
|
+
key: str,
|
|
57
|
+
algorithm: Algorithm | None = None,
|
|
58
|
+
limit: int | None = None,
|
|
59
|
+
window_ms: int | None = None,
|
|
60
|
+
) -> LimitResult:
|
|
61
|
+
body = _core.limit_body(namespace, key, algorithm, limit, window_ms)
|
|
62
|
+
try:
|
|
63
|
+
data = await self._request("POST", _core.LIMIT_PATH, body)
|
|
64
|
+
except DetentError as err:
|
|
65
|
+
if _core.should_degrade(err):
|
|
66
|
+
if self.on_error is not None:
|
|
67
|
+
self.on_error(err)
|
|
68
|
+
return _core.degraded_limit(self.fail_mode, limit)
|
|
69
|
+
raise
|
|
70
|
+
return _core.parse_limit(data)
|
|
71
|
+
|
|
72
|
+
async def acquire(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
namespace: str,
|
|
76
|
+
key: str,
|
|
77
|
+
limit: int | None = None,
|
|
78
|
+
window_ms: int | None = None,
|
|
79
|
+
) -> AcquireResult:
|
|
80
|
+
data = await self._request(
|
|
81
|
+
"POST", _core.LEASES_PATH, _core.acquire_body(namespace, key, limit, window_ms)
|
|
82
|
+
)
|
|
83
|
+
return _core.parse_acquire(data)
|
|
84
|
+
|
|
85
|
+
async def release(self, lease_id: str) -> ReleaseResult:
|
|
86
|
+
return _core.parse_release(await self._request("DELETE", _core.release_path(lease_id)))
|
|
87
|
+
|
|
88
|
+
@asynccontextmanager
|
|
89
|
+
async def lease(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
namespace: str,
|
|
93
|
+
key: str,
|
|
94
|
+
limit: int | None = None,
|
|
95
|
+
window_ms: int | None = None,
|
|
96
|
+
) -> AsyncIterator[AcquireResult]:
|
|
97
|
+
acq = await self.acquire(namespace=namespace, key=key, limit=limit, window_ms=window_ms)
|
|
98
|
+
if not acq.allowed:
|
|
99
|
+
raise DetentLeaseDenied(acq)
|
|
100
|
+
try:
|
|
101
|
+
yield acq
|
|
102
|
+
except BaseException:
|
|
103
|
+
if acq.lease_id is not None:
|
|
104
|
+
try:
|
|
105
|
+
await self.release(acq.lease_id)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass # never mask the in-body exception
|
|
108
|
+
raise
|
|
109
|
+
else:
|
|
110
|
+
if acq.lease_id is not None:
|
|
111
|
+
await self.release(acq.lease_id)
|
|
112
|
+
|
|
113
|
+
async def get_stats(self, *, namespace: str) -> StatsResult:
|
|
114
|
+
return _core.parse_stats(await self._request("GET", _core.stats_path(namespace)))
|
|
115
|
+
|
|
116
|
+
async def aclose(self) -> None:
|
|
117
|
+
await self._client.aclose()
|
|
118
|
+
|
|
119
|
+
async def __aenter__(self) -> AsyncDetent:
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
async def __aexit__(
|
|
123
|
+
self,
|
|
124
|
+
exc_type: type[BaseException] | None,
|
|
125
|
+
exc: BaseException | None,
|
|
126
|
+
tb: TracebackType | None,
|
|
127
|
+
) -> None:
|
|
128
|
+
await self.aclose()
|
detent/client.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from . import _core
|
|
11
|
+
from ._config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, FailMode
|
|
12
|
+
from .errors import DetentError, DetentLeaseDenied, DetentTransportError
|
|
13
|
+
from .models import AcquireResult, Algorithm, LimitResult, ReleaseResult, StatsResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Detent:
|
|
17
|
+
"""Synchronous Detent client."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
api_key: str,
|
|
22
|
+
*,
|
|
23
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
24
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
25
|
+
fail_mode: FailMode = "open",
|
|
26
|
+
on_error: Callable[[DetentError], None] | None = None,
|
|
27
|
+
transport: httpx.BaseTransport | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
if not api_key:
|
|
30
|
+
raise ValueError("api_key is required")
|
|
31
|
+
self.fail_mode: FailMode = fail_mode
|
|
32
|
+
self.on_error = on_error
|
|
33
|
+
self._client = httpx.Client(
|
|
34
|
+
base_url=base_url.rstrip("/"),
|
|
35
|
+
timeout=timeout,
|
|
36
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
37
|
+
transport=transport,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _request(
|
|
41
|
+
self, method: str, path: str, body: dict[str, Any] | None = None
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
try:
|
|
44
|
+
resp = self._client.request(method, path, json=body)
|
|
45
|
+
except httpx.TransportError as exc:
|
|
46
|
+
raise DetentTransportError("Detent request failed") from exc
|
|
47
|
+
if resp.is_success:
|
|
48
|
+
data: dict[str, Any] = resp.json()
|
|
49
|
+
return data
|
|
50
|
+
raise _core.api_error(resp.status_code, _core.error_body(resp.status_code, resp.text))
|
|
51
|
+
|
|
52
|
+
def limit(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
namespace: str,
|
|
56
|
+
key: str,
|
|
57
|
+
algorithm: Algorithm | None = None,
|
|
58
|
+
limit: int | None = None,
|
|
59
|
+
window_ms: int | None = None,
|
|
60
|
+
) -> LimitResult:
|
|
61
|
+
body = _core.limit_body(namespace, key, algorithm, limit, window_ms)
|
|
62
|
+
try:
|
|
63
|
+
data = self._request("POST", _core.LIMIT_PATH, body)
|
|
64
|
+
except DetentError as err:
|
|
65
|
+
if _core.should_degrade(err):
|
|
66
|
+
if self.on_error is not None:
|
|
67
|
+
self.on_error(err)
|
|
68
|
+
return _core.degraded_limit(self.fail_mode, limit)
|
|
69
|
+
raise
|
|
70
|
+
return _core.parse_limit(data)
|
|
71
|
+
|
|
72
|
+
def acquire(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
namespace: str,
|
|
76
|
+
key: str,
|
|
77
|
+
limit: int | None = None,
|
|
78
|
+
window_ms: int | None = None,
|
|
79
|
+
) -> AcquireResult:
|
|
80
|
+
data = self._request(
|
|
81
|
+
"POST", _core.LEASES_PATH, _core.acquire_body(namespace, key, limit, window_ms)
|
|
82
|
+
)
|
|
83
|
+
return _core.parse_acquire(data)
|
|
84
|
+
|
|
85
|
+
def release(self, lease_id: str) -> ReleaseResult:
|
|
86
|
+
return _core.parse_release(self._request("DELETE", _core.release_path(lease_id)))
|
|
87
|
+
|
|
88
|
+
@contextmanager
|
|
89
|
+
def lease(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
namespace: str,
|
|
93
|
+
key: str,
|
|
94
|
+
limit: int | None = None,
|
|
95
|
+
window_ms: int | None = None,
|
|
96
|
+
) -> Iterator[AcquireResult]:
|
|
97
|
+
acq = self.acquire(namespace=namespace, key=key, limit=limit, window_ms=window_ms)
|
|
98
|
+
if not acq.allowed:
|
|
99
|
+
raise DetentLeaseDenied(acq)
|
|
100
|
+
try:
|
|
101
|
+
yield acq
|
|
102
|
+
except BaseException:
|
|
103
|
+
if acq.lease_id is not None:
|
|
104
|
+
try:
|
|
105
|
+
self.release(acq.lease_id)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass # never mask the in-body exception
|
|
108
|
+
raise
|
|
109
|
+
else:
|
|
110
|
+
if acq.lease_id is not None:
|
|
111
|
+
self.release(acq.lease_id)
|
|
112
|
+
|
|
113
|
+
def get_stats(self, *, namespace: str) -> StatsResult:
|
|
114
|
+
return _core.parse_stats(self._request("GET", _core.stats_path(namespace)))
|
|
115
|
+
|
|
116
|
+
def close(self) -> None:
|
|
117
|
+
self._client.close()
|
|
118
|
+
|
|
119
|
+
def __enter__(self) -> Detent:
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def __exit__(
|
|
123
|
+
self,
|
|
124
|
+
exc_type: type[BaseException] | None,
|
|
125
|
+
exc: BaseException | None,
|
|
126
|
+
tb: TracebackType | None,
|
|
127
|
+
) -> None:
|
|
128
|
+
self.close()
|
detent/errors.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .models import AcquireResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DetentError(Exception):
|
|
10
|
+
"""Base class for all Detent SDK errors."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DetentAPIError(DetentError):
|
|
14
|
+
"""A 4xx/5xx response from the Detent API."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, status: int, body: dict[str, str]) -> None:
|
|
17
|
+
super().__init__(f"Detent API error {status}: {body.get('error', '')}")
|
|
18
|
+
self.status = status
|
|
19
|
+
self.body = body
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DetentTransportError(DetentError):
|
|
23
|
+
"""A network/DNS/timeout failure reaching the Detent API."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DetentLeaseDenied(DetentError):
|
|
27
|
+
"""Raised by lease() when a concurrent slot could not be acquired."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, result: AcquireResult) -> None:
|
|
30
|
+
super().__init__("Lease denied: no concurrent slot available")
|
|
31
|
+
self.result = result
|
detent/models.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
Algorithm = Literal["sliding_window", "token_bucket", "fixed_window"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class LimitResult:
|
|
11
|
+
allowed: bool
|
|
12
|
+
remaining: int
|
|
13
|
+
reset_ms: int
|
|
14
|
+
limit: int
|
|
15
|
+
degraded: bool
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AcquireResult:
|
|
20
|
+
allowed: bool
|
|
21
|
+
lease_id: str | None
|
|
22
|
+
active: int
|
|
23
|
+
limit: int
|
|
24
|
+
reset_ms: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ReleaseResult:
|
|
29
|
+
released: bool
|
|
30
|
+
active: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class DayStat:
|
|
35
|
+
day: str
|
|
36
|
+
total: int
|
|
37
|
+
blocked: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class MonthSummary:
|
|
42
|
+
month: str
|
|
43
|
+
total: int
|
|
44
|
+
quota: int | None
|
|
45
|
+
over_quota: bool
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class StatsResult:
|
|
50
|
+
namespace: str
|
|
51
|
+
total: int
|
|
52
|
+
blocked: int
|
|
53
|
+
days: list[DayStat]
|
|
54
|
+
month: MonthSummary
|
detent/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: detent-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed Python client for the Detent rate-limiting API.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cguillerminet/detent-sdk-python
|
|
6
|
+
Project-URL: Issues, https://github.com/cguillerminet/detent-sdk-python/issues
|
|
7
|
+
Author: Cyril GUILLERMINET
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api-client,detent,rate-limit,rate-limiter,sdk
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
17
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# detent-sdk
|
|
21
|
+
|
|
22
|
+
Typed Python client for the [Detent](https://detent.dev) rate-limiting API. Sync and async, one dependency (httpx).
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install detent-sdk
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from detent import Detent
|
|
32
|
+
|
|
33
|
+
rg = Detent(api_key="rg_live_...")
|
|
34
|
+
|
|
35
|
+
# Rate-limit check (fails open by default on transport error or 5xx)
|
|
36
|
+
r = rg.limit(namespace="api", key=user_id)
|
|
37
|
+
if not r.allowed:
|
|
38
|
+
... # return HTTP 429
|
|
39
|
+
|
|
40
|
+
# Concurrent-limit lease (auto-released)
|
|
41
|
+
with rg.lease(namespace="jobs", key=user_id):
|
|
42
|
+
run_expensive_job()
|
|
43
|
+
|
|
44
|
+
# Read-only usage stats
|
|
45
|
+
stats = rg.get_stats(namespace="api")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Async
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from detent import AsyncDetent
|
|
52
|
+
|
|
53
|
+
async with AsyncDetent(api_key="rg_live_...") as rg:
|
|
54
|
+
r = await rg.limit(namespace="api", key=user_id)
|
|
55
|
+
async with rg.lease(namespace="jobs", key=user_id):
|
|
56
|
+
await run_expensive_job()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Configuration
|
|
60
|
+
|
|
61
|
+
| Option | Default | Notes |
|
|
62
|
+
|-------------|----------------------------|----------------------------------------------------------|
|
|
63
|
+
| `api_key` | — (required) | `rg_live_…` / `rg_test_…` |
|
|
64
|
+
| `base_url` | `https://api.detent.dev` | Override for self-host / tests |
|
|
65
|
+
| `timeout` | `1.0` | Seconds; client-side transport timeout |
|
|
66
|
+
| `fail_mode` | `"open"` | `"open"` allows, `"closed"` denies on a degraded backend |
|
|
67
|
+
| `on_error` | `None` | Called on a degraded (transport/5xx) `limit()` call |
|
|
68
|
+
|
|
69
|
+
`limit()` never raises on a degraded backend (transport error or 5xx) — it
|
|
70
|
+
returns a result with `degraded=True`. A `4xx` (bad key, plan gate, unknown
|
|
71
|
+
rule) raises `DetentAPIError`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
detent/__init__.py,sha256=W8pwA1cQrostXovgU6kQI-Ure4c7XvzDeVzz8K1P0nk,652
|
|
2
|
+
detent/_config.py,sha256=PmDrIbNMR6Z4cCAI2rDLaGgOAYwwd2AUmC3b_Ukmo7A,168
|
|
3
|
+
detent/_core.py,sha256=z5o7UoMfk6gF1q_iwOUlyS1ihbr-6BGieh-PpRToPRU,3291
|
|
4
|
+
detent/async_client.py,sha256=tJKUicp5Vv7OmholCXpltZgXwT-jiHUR50sS3ZUdC9Y,4199
|
|
5
|
+
detent/client.py,sha256=vMRU9kKTBZK775srjYNDROI7LSmpt5w2Waz3OVhby-c,4039
|
|
6
|
+
detent/errors.py,sha256=7G3rmMfXBEH9ZN8x3Qpf4amBbgr73GgDmUa_KBZx3qM,873
|
|
7
|
+
detent/models.py,sha256=Cl7OF_Oo1LOGQOvsNSxS-7WTTKucyjX7Sho_JZFqAvo,872
|
|
8
|
+
detent/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
detent_sdk-0.1.0.dist-info/METADATA,sha256=vWFgLjfCdhBtUqWbenXLdKhU7gQaMkr1nxaoOqNysIc,2471
|
|
10
|
+
detent_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
detent_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=16r9xrd4FhxMgENcfa02GE7epOzbRJB8N_7RIsRuIbA,1075
|
|
12
|
+
detent_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cyril Guillerminet
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|