pace-python 1.0.1__cp38-abi3-win_amd64.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.
- pace_python-1.0.1.dist-info/METADATA +11 -0
- pace_python-1.0.1.dist-info/RECORD +13 -0
- pace_python-1.0.1.dist-info/WHEEL +4 -0
- pace_python-1.0.1.dist-info/sboms/pace-python.cyclonedx.json +7751 -0
- pace_sdk/__init__.py +20 -0
- pace_sdk/client.py +57 -0
- pace_sdk/core.py +62 -0
- pace_sdk/helpers.py +32 -0
- pace_sdk/logger.py +64 -0
- pace_sdk/middleware.py +49 -0
- pace_sdk/pace_native.pyd +0 -0
- pace_sdk/telemetry.py +72 -0
- pace_sdk/types.py +97 -0
pace_sdk/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# pace_sdk/__init__.py
|
|
2
|
+
|
|
3
|
+
# 1. Export the Core Engine
|
|
4
|
+
from .core import Pace
|
|
5
|
+
|
|
6
|
+
# 2. Export the Types
|
|
7
|
+
from .types import PaceConfig, ProtectionMode, Algorithm
|
|
8
|
+
|
|
9
|
+
# 3. Export the Framework Helpers
|
|
10
|
+
from .helpers import PaceFastAPI, pace_django
|
|
11
|
+
|
|
12
|
+
# Define exactly what gets imported when someone uses `from pace_sdk import *`
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Pace",
|
|
15
|
+
"PaceConfig",
|
|
16
|
+
"ProtectionMode",
|
|
17
|
+
"Algorithm",
|
|
18
|
+
"PaceFastAPI",
|
|
19
|
+
"pace_django"
|
|
20
|
+
]
|
pace_sdk/client.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
from .pace_native import RustEngine
|
|
4
|
+
from .logger import log_decision
|
|
5
|
+
from .types import CheckResult, PaceConfig, CanonicalDecision, TrafficDecision, DecisionReason, Algorithm
|
|
6
|
+
|
|
7
|
+
class Pace:
|
|
8
|
+
def __init__(self, config: PaceConfig):
|
|
9
|
+
self.config = config
|
|
10
|
+
self.log_mode = config.debug
|
|
11
|
+
if os.environ.get("PACE_DEBUG") == "true":
|
|
12
|
+
print("[Pace] ⚡ Rust engine loaded")
|
|
13
|
+
|
|
14
|
+
debug_str = config.debug if isinstance(config.debug, str) else ("compact" if config.debug else None)
|
|
15
|
+
|
|
16
|
+
self._native_engine = RustEngine(
|
|
17
|
+
config.algorithm.value,
|
|
18
|
+
config.mode.value,
|
|
19
|
+
float(config.capacity),
|
|
20
|
+
float(config.refill_rate),
|
|
21
|
+
config.api_key,
|
|
22
|
+
debug_str,
|
|
23
|
+
config.backend_url
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def check(self, ip: str, route: str = "/", key: Optional[str] = None) -> CheckResult:
|
|
27
|
+
# Pass everything to Rust. No Python math, no locks, no state dicts!
|
|
28
|
+
res = self._native_engine.check(ip, route, key)
|
|
29
|
+
|
|
30
|
+
dec_data = res.get("decision", {})
|
|
31
|
+
decision = CanonicalDecision(
|
|
32
|
+
decision=TrafficDecision(dec_data.get("decision", "allow")),
|
|
33
|
+
reason=DecisionReason(dec_data.get("reason", "within_limit")),
|
|
34
|
+
algorithm=Algorithm(dec_data.get("algorithm", "token_bucket")),
|
|
35
|
+
route=dec_data.get("route", route),
|
|
36
|
+
ip=ip,
|
|
37
|
+
key=key or ip,
|
|
38
|
+
remaining=dec_data.get("remaining"),
|
|
39
|
+
latency_ms=dec_data.get("latency_ms", 0),
|
|
40
|
+
mode=self.config.mode
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
result = CheckResult(
|
|
44
|
+
allowed=res.get("allowed", True),
|
|
45
|
+
would_block=res.get("would_block", False),
|
|
46
|
+
reason=res.get("reason"),
|
|
47
|
+
decision=decision
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
log_decision(self.log_mode, decision)
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
def check_detailed(self, ip: str, route: str = "/", key: Optional[str] = None) -> CheckResult:
|
|
54
|
+
return self.check(ip, route, key)
|
|
55
|
+
|
|
56
|
+
def check_with_key(self, key: str, ip: str, route: str = "/") -> CheckResult:
|
|
57
|
+
return self.check(ip, route, key)
|
pace_sdk/core.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# sdk-python/pace_sdk/core.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from .pace_native import RustEngine
|
|
7
|
+
from .logger import log_decision
|
|
8
|
+
from .types import CheckResult, PaceConfig, CanonicalDecision, TrafficDecision, DecisionReason, Algorithm
|
|
9
|
+
|
|
10
|
+
class Pace:
|
|
11
|
+
def __init__(self, config: PaceConfig):
|
|
12
|
+
self.config = config
|
|
13
|
+
self._lock = threading.Lock()
|
|
14
|
+
self.log_mode = config.debug
|
|
15
|
+
if os.environ.get("PACE_DEBUG") == "true":
|
|
16
|
+
print("[Pace] ⚡ Rust engine loaded")
|
|
17
|
+
|
|
18
|
+
debug_str = config.debug if isinstance(config.debug, str) else ("compact" if config.debug else None)
|
|
19
|
+
|
|
20
|
+
self._native_engine = RustEngine(
|
|
21
|
+
config.algorithm.value,
|
|
22
|
+
config.mode.value,
|
|
23
|
+
float(config.capacity),
|
|
24
|
+
float(config.refill_rate),
|
|
25
|
+
config.api_key,
|
|
26
|
+
debug_str,
|
|
27
|
+
config.backend_url
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def check(self, ip: str, route: str = "/", key: Optional[str] = None) -> CheckResult:
|
|
31
|
+
with self._lock:
|
|
32
|
+
# Pass everything to Rust. No Python math, no locks, no state dicts!
|
|
33
|
+
res = self._native_engine.check(ip, route, key)
|
|
34
|
+
|
|
35
|
+
dec_data = res.get("decision", {})
|
|
36
|
+
decision = CanonicalDecision(
|
|
37
|
+
decision=TrafficDecision(dec_data.get("decision", "allow")),
|
|
38
|
+
reason=DecisionReason(dec_data.get("reason", "within_limit")),
|
|
39
|
+
algorithm=Algorithm(dec_data.get("algorithm", "token_bucket")),
|
|
40
|
+
route=dec_data.get("route", route),
|
|
41
|
+
ip=ip,
|
|
42
|
+
key=key or ip,
|
|
43
|
+
remaining=dec_data.get("remaining"),
|
|
44
|
+
latency_ms=dec_data.get("latency_ms", 0),
|
|
45
|
+
mode=self.config.mode
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
result = CheckResult(
|
|
49
|
+
allowed=res.get("allowed", True),
|
|
50
|
+
would_block=res.get("would_block", False),
|
|
51
|
+
reason=res.get("reason"),
|
|
52
|
+
decision=decision
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
log_decision(self.log_mode, decision)
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
def check_detailed(self, ip: str, route: str = "/", key: Optional[str] = None) -> CheckResult:
|
|
59
|
+
return self.check(ip, route, key)
|
|
60
|
+
|
|
61
|
+
def check_with_key(self, key: str, ip: str, route: str = "/") -> CheckResult:
|
|
62
|
+
return self.check(ip, route, key)
|
pace_sdk/helpers.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# pace_sdk/helpers.py
|
|
2
|
+
from typing import Callable
|
|
3
|
+
from functools import wraps
|
|
4
|
+
|
|
5
|
+
class PaceFastAPI:
|
|
6
|
+
def __init__(self, pace_engine):
|
|
7
|
+
self.pace = pace_engine
|
|
8
|
+
|
|
9
|
+
def limit(self, route: str = "/"):
|
|
10
|
+
from fastapi import Request, HTTPException
|
|
11
|
+
def _dependency(request: Request):
|
|
12
|
+
client_ip = request.client.host if request.client else "127.0.0.1"
|
|
13
|
+
result = self.pace.check(ip=client_ip, route=route)
|
|
14
|
+
if not result.allowed:
|
|
15
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
16
|
+
return _dependency
|
|
17
|
+
|
|
18
|
+
def pace_django(pace_engine, route: str = "/"):
|
|
19
|
+
def decorator(view_func: Callable) -> Callable:
|
|
20
|
+
@wraps(view_func)
|
|
21
|
+
def _wrapped_view(request, *args, **kwargs):
|
|
22
|
+
from django.http import JsonResponse
|
|
23
|
+
|
|
24
|
+
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
|
25
|
+
client_ip = x_forwarded_for.split(',')[0].strip() if x_forwarded_for else request.META.get('REMOTE_ADDR', '127.0.0.1')
|
|
26
|
+
|
|
27
|
+
result = pace_engine.check(ip=client_ip, route=route)
|
|
28
|
+
if not result.allowed:
|
|
29
|
+
return JsonResponse({"error": "Rate limit exceeded"}, status=429)
|
|
30
|
+
return view_func(request, *args, **kwargs)
|
|
31
|
+
return _wrapped_view
|
|
32
|
+
return decorator
|
pace_sdk/logger.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
from .types import CanonicalDecision, TrafficDecision
|
|
3
|
+
|
|
4
|
+
# Global Color Definitions
|
|
5
|
+
RED = "\033[91m"
|
|
6
|
+
GREEN = "\033[92m"
|
|
7
|
+
YELLOW = "\033[93m"
|
|
8
|
+
RESET = "\033[0m"
|
|
9
|
+
|
|
10
|
+
def should_ignore_route(route: str) -> bool:
|
|
11
|
+
return route in {"/favicon.ico", "/health", "/api/health", "/healthz", "/ping", "/api/ping"} or route.startswith("/_next")
|
|
12
|
+
|
|
13
|
+
def _format_duration_ms(ms: int) -> str:
|
|
14
|
+
if ms <= 0: return "0ms"
|
|
15
|
+
if ms % 3_600_000 == 0: return f"{ms // 3_600_000}h"
|
|
16
|
+
if ms % 60_000 == 0: return f"{ms // 60_000}m"
|
|
17
|
+
if ms % 1000 == 0: return f"{ms // 1000}s"
|
|
18
|
+
return f"{ms}ms"
|
|
19
|
+
|
|
20
|
+
def log_decision(debug_mode: Union[bool, str], decision: CanonicalDecision) -> None:
|
|
21
|
+
if not debug_mode or should_ignore_route(decision.route):
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
# 1. COMPACT MODE
|
|
25
|
+
if debug_mode == "compact" or debug_mode is True:
|
|
26
|
+
if decision.decision == TrafficDecision.ALLOW:
|
|
27
|
+
headline = f"{GREEN}ALLOW{RESET}"
|
|
28
|
+
else:
|
|
29
|
+
headline = f"{RED}BLOCK{RESET}"
|
|
30
|
+
|
|
31
|
+
parts = [f"[Pace] {headline}", f"route={decision.route}"]
|
|
32
|
+
if decision.remaining is not None: parts.append(f"remaining={decision.remaining}")
|
|
33
|
+
if decision.latency_ms is not None: parts.append(f"latency={decision.latency_ms}ms")
|
|
34
|
+
print(" ".join(parts))
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
# 2. PRETTY MODE
|
|
38
|
+
if debug_mode == "pretty":
|
|
39
|
+
if decision.decision == TrafficDecision.ALLOW:
|
|
40
|
+
print(f"{GREEN}[Pace] 🟢 ALLOWED{RESET}")
|
|
41
|
+
print(f"│ Route: {decision.route}")
|
|
42
|
+
if decision.remaining is not None:
|
|
43
|
+
print(f"│ Remaining: {decision.remaining}")
|
|
44
|
+
if decision.latency_ms is not None:
|
|
45
|
+
print(f"│ Latency: {decision.latency_ms}ms")
|
|
46
|
+
print(f"└────────────────────────────────────────")
|
|
47
|
+
else:
|
|
48
|
+
headline = "🔴 BLOCKED" if decision.decision == TrafficDecision.BLOCK else "🟡 WOULD BLOCK"
|
|
49
|
+
color = RED if decision.decision == TrafficDecision.BLOCK else YELLOW
|
|
50
|
+
|
|
51
|
+
print(f"{color}[Pace] {headline}{RESET}")
|
|
52
|
+
print(f"│ Route: {decision.route}")
|
|
53
|
+
print(f"│ IP: {decision.ip or 'unknown'}")
|
|
54
|
+
print(f"│ Algorithm: {decision.algorithm.value}")
|
|
55
|
+
print(f"│ Reason: {decision.reason.value}")
|
|
56
|
+
if decision.mode:
|
|
57
|
+
print(f"│ Mode: {decision.mode.value}")
|
|
58
|
+
if decision.remaining is not None:
|
|
59
|
+
print(f"│ Remaining: {decision.remaining}")
|
|
60
|
+
if decision.refill_ms:
|
|
61
|
+
print(f"│ Refill: {_format_duration_ms(decision.refill_ms)}")
|
|
62
|
+
if decision.latency_ms is not None:
|
|
63
|
+
print(f"│ Latency: {decision.latency_ms}ms")
|
|
64
|
+
print(f"└────────────────────────────────────────")
|
pace_sdk/middleware.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
|
|
3
|
+
from .client import Pace
|
|
4
|
+
from .types import PaceConfig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def flask_middleware(pace: Pace):
|
|
8
|
+
"""Flask decorator"""
|
|
9
|
+
|
|
10
|
+
def decorator(f):
|
|
11
|
+
@wraps(f)
|
|
12
|
+
def wrapper(*args, **kwargs):
|
|
13
|
+
from flask import jsonify, request
|
|
14
|
+
|
|
15
|
+
ip = request.headers.get("X-Forwarded-For", request.remote_addr)
|
|
16
|
+
identity = request.headers.get(pace.config.identity_header) if pace.config.identity_header else None
|
|
17
|
+
result = pace.check_with_key(identity or "", ip, request.path)
|
|
18
|
+
if not result.allowed:
|
|
19
|
+
return jsonify({"message": "Rate limit exceeded"}), 429
|
|
20
|
+
return f(*args, **kwargs)
|
|
21
|
+
|
|
22
|
+
return wrapper
|
|
23
|
+
|
|
24
|
+
return decorator
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FastAPIMiddleware:
|
|
28
|
+
"""FastAPI middleware"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, app, config: PaceConfig):
|
|
31
|
+
self.app = app
|
|
32
|
+
self.pace = Pace(config)
|
|
33
|
+
|
|
34
|
+
async def __call__(self, scope, receive, send):
|
|
35
|
+
if scope["type"] == "http":
|
|
36
|
+
from starlette.requests import Request
|
|
37
|
+
from starlette.responses import JSONResponse
|
|
38
|
+
|
|
39
|
+
request = Request(scope, receive)
|
|
40
|
+
ip = request.headers.get(
|
|
41
|
+
"x-forwarded-for", request.client.host if request.client else "unknown"
|
|
42
|
+
)
|
|
43
|
+
identity = request.headers.get(self.pace.config.identity_header) if self.pace.config.identity_header else None
|
|
44
|
+
result = self.pace.check_with_key(identity or "", ip, request.url.path)
|
|
45
|
+
if not result.allowed:
|
|
46
|
+
response = JSONResponse({"message": "Rate limit exceeded"}, status_code=429)
|
|
47
|
+
await response(scope, receive, send)
|
|
48
|
+
return
|
|
49
|
+
await self.app(scope, receive, send)
|
pace_sdk/pace_native.pyd
ADDED
|
Binary file
|
pace_sdk/telemetry.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import asdict, is_dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .types import CanonicalTelemetryEvent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TelemetryQueue:
|
|
13
|
+
def __init__(self, api_key: str, backend_url: str, enabled: bool):
|
|
14
|
+
self.api_key = api_key
|
|
15
|
+
self.backend_url = backend_url
|
|
16
|
+
self.enabled = enabled
|
|
17
|
+
self.queue: List[CanonicalTelemetryEvent] = []
|
|
18
|
+
self.lock = threading.Lock()
|
|
19
|
+
self.MAX_SIZE = 10000
|
|
20
|
+
self.BATCH_SIZE = 500
|
|
21
|
+
|
|
22
|
+
if enabled:
|
|
23
|
+
t = threading.Thread(target=self._flush_loop, daemon=True)
|
|
24
|
+
t.start()
|
|
25
|
+
|
|
26
|
+
def push(self, event: CanonicalTelemetryEvent):
|
|
27
|
+
if not self.enabled:
|
|
28
|
+
return
|
|
29
|
+
with self.lock:
|
|
30
|
+
if len(self.queue) >= self.MAX_SIZE:
|
|
31
|
+
self.queue.pop(0)
|
|
32
|
+
self.queue.append(event)
|
|
33
|
+
|
|
34
|
+
def _flush_loop(self):
|
|
35
|
+
while True:
|
|
36
|
+
time.sleep(2)
|
|
37
|
+
self._flush()
|
|
38
|
+
|
|
39
|
+
def _flush(self):
|
|
40
|
+
with self.lock:
|
|
41
|
+
if not self.queue:
|
|
42
|
+
return
|
|
43
|
+
batch = self.queue[: self.BATCH_SIZE]
|
|
44
|
+
self.queue = self.queue[self.BATCH_SIZE :]
|
|
45
|
+
|
|
46
|
+
def encode(value):
|
|
47
|
+
if isinstance(value, Enum):
|
|
48
|
+
return value.value
|
|
49
|
+
if is_dataclass(value):
|
|
50
|
+
return {
|
|
51
|
+
key: encode(inner)
|
|
52
|
+
for key, inner in asdict(value).items()
|
|
53
|
+
if inner is not None
|
|
54
|
+
}
|
|
55
|
+
if isinstance(value, list):
|
|
56
|
+
return [encode(item) for item in value]
|
|
57
|
+
if isinstance(value, dict):
|
|
58
|
+
return {
|
|
59
|
+
key: encode(inner)
|
|
60
|
+
for key, inner in value.items()
|
|
61
|
+
if inner is not None
|
|
62
|
+
}
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
requests.post(
|
|
67
|
+
f"{self.backend_url}/api/ingest/request",
|
|
68
|
+
json={"events": [encode(event) for event in batch]},
|
|
69
|
+
timeout=5,
|
|
70
|
+
)
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
pace_sdk/types.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional, Union
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
class ProtectionMode(str, Enum):
|
|
6
|
+
ACTIVE = "active"
|
|
7
|
+
SHADOW = "shadow"
|
|
8
|
+
DISABLED = "disabled"
|
|
9
|
+
|
|
10
|
+
class Algorithm(str, Enum):
|
|
11
|
+
TOKEN_BUCKET = "token_bucket"
|
|
12
|
+
SLIDING_WINDOW = "sliding_window"
|
|
13
|
+
FIXED_WINDOW = "fixed_window"
|
|
14
|
+
LEAKY_BUCKET = "leaky_bucket"
|
|
15
|
+
|
|
16
|
+
class TrafficDecision(str, Enum):
|
|
17
|
+
ALLOW = "allow"
|
|
18
|
+
BLOCK = "block"
|
|
19
|
+
WOULD_BLOCK = "would_block"
|
|
20
|
+
|
|
21
|
+
class DecisionReason(str, Enum):
|
|
22
|
+
WITHIN_LIMIT = "within_limit"
|
|
23
|
+
LIMIT_EXCEEDED = "limit_exceeded"
|
|
24
|
+
TOKEN_EXHAUSTED = "token_exhausted"
|
|
25
|
+
|
|
26
|
+
# REMOVED: LogMode Enum is no longer needed!
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CanonicalDecision:
|
|
30
|
+
decision: TrafficDecision
|
|
31
|
+
reason: DecisionReason
|
|
32
|
+
algorithm: Algorithm
|
|
33
|
+
route: str
|
|
34
|
+
key: Optional[str] = None
|
|
35
|
+
remaining: Optional[int] = None
|
|
36
|
+
reset_ms: Optional[int] = None
|
|
37
|
+
latency_ms: Optional[int] = None
|
|
38
|
+
mode: ProtectionMode = ProtectionMode.ACTIVE
|
|
39
|
+
timestamp: int = 0
|
|
40
|
+
ip: Optional[str] = None
|
|
41
|
+
window: Optional[str] = None
|
|
42
|
+
limit: Optional[int] = None
|
|
43
|
+
capacity: Optional[int] = None
|
|
44
|
+
refill_rate: Optional[float] = None
|
|
45
|
+
refill_ms: Optional[int] = None
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class CanonicalTelemetryRequest:
|
|
49
|
+
route: str
|
|
50
|
+
ip: str
|
|
51
|
+
method: Optional[str] = None
|
|
52
|
+
status_code: Optional[int] = None
|
|
53
|
+
latency_ms: Optional[int] = None
|
|
54
|
+
user_agent: Optional[str] = None
|
|
55
|
+
key: Optional[str] = None
|
|
56
|
+
mode: ProtectionMode = ProtectionMode.ACTIVE
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class CanonicalTelemetryEvent:
|
|
60
|
+
event_type: str
|
|
61
|
+
timestamp: int
|
|
62
|
+
decision: CanonicalDecision
|
|
63
|
+
request: CanonicalTelemetryRequest
|
|
64
|
+
api_key: Optional[str] = None
|
|
65
|
+
sdk_version: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Rules:
|
|
69
|
+
requests_per_minute: Optional[int] = None
|
|
70
|
+
requests_per_second: Optional[int] = None
|
|
71
|
+
burst_limit: Optional[int] = None
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Thresholds:
|
|
75
|
+
burst: int = 20
|
|
76
|
+
block_duration_ms: int = 60000
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class PaceConfig:
|
|
80
|
+
api_key: Optional[str] = None
|
|
81
|
+
mode: ProtectionMode = ProtectionMode.ACTIVE
|
|
82
|
+
algorithm: Algorithm = Algorithm.TOKEN_BUCKET
|
|
83
|
+
capacity: int = 100
|
|
84
|
+
refill_rate: int = 10
|
|
85
|
+
# Unified debug setting: False, True, "compact", or "pretty"
|
|
86
|
+
debug: Union[bool, str] = False
|
|
87
|
+
identity_header: Optional[str] = None
|
|
88
|
+
backend_url: str = "http://localhost:4000"
|
|
89
|
+
rules: Rules = field(default_factory=Rules)
|
|
90
|
+
thresholds: Thresholds = field(default_factory=Thresholds)
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class CheckResult:
|
|
94
|
+
allowed: bool
|
|
95
|
+
would_block: bool
|
|
96
|
+
reason: Optional[str] = None
|
|
97
|
+
decision: Optional[CanonicalDecision] = None
|