ordainer 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.
- ordainer/__init__.py +170 -0
- ordainer/async_client.py +243 -0
- ordainer/client.py +207 -0
- ordainer/decision.py +108 -0
- ordainer/deny.py +301 -0
- ordainer/engine.py +630 -0
- ordainer/errors.py +51 -0
- ordainer/py.typed +0 -0
- ordainer/transport.py +107 -0
- ordainer/version.py +2 -0
- ordainer-0.1.0.dist-info/METADATA +178 -0
- ordainer-0.1.0.dist-info/RECORD +15 -0
- ordainer-0.1.0.dist-info/WHEEL +5 -0
- ordainer-0.1.0.dist-info/licenses/LICENSE +21 -0
- ordainer-0.1.0.dist-info/top_level.txt +1 -0
ordainer/__init__.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Ask Ordainer before your tool runs.
|
|
2
|
+
|
|
3
|
+
One decide() call checks the caller's plan and counts the use. Python 3.9+,
|
|
4
|
+
standard library only. Mirrors the Ruby gem (packages/ordainer-ruby) and the
|
|
5
|
+
Node SDK (packages/ordainer-node).
|
|
6
|
+
|
|
7
|
+
from ordainer import Client, deny_response
|
|
8
|
+
|
|
9
|
+
client = Client(api_key=os.environ["ORDAINER_API_KEY"])
|
|
10
|
+
decision = client.decide(tool="research.run")
|
|
11
|
+
if not decision.allowed:
|
|
12
|
+
blocked = deny_response(decision)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
from typing import Any, Callable, Optional
|
|
20
|
+
|
|
21
|
+
from .client import Client
|
|
22
|
+
from .decision import Decision, ErrorInfo
|
|
23
|
+
from .deny import (
|
|
24
|
+
BILLING_BLOCKED_MESSAGE,
|
|
25
|
+
CONCURRENCY_LIMITED_MESSAGE,
|
|
26
|
+
RATE_LIMITED_MESSAGE,
|
|
27
|
+
UNAVAILABLE_MESSAGE,
|
|
28
|
+
deny_response,
|
|
29
|
+
plan_denied_message,
|
|
30
|
+
rejected_message,
|
|
31
|
+
reset_day_utc,
|
|
32
|
+
test_limit_message,
|
|
33
|
+
)
|
|
34
|
+
from .engine import idempotency_key_for
|
|
35
|
+
from .errors import (
|
|
36
|
+
BlockedError,
|
|
37
|
+
ConfigurationError,
|
|
38
|
+
DeniedError,
|
|
39
|
+
Error,
|
|
40
|
+
NetworkError,
|
|
41
|
+
RateLimitedError,
|
|
42
|
+
RejectedError,
|
|
43
|
+
TimeoutError,
|
|
44
|
+
UnavailableError,
|
|
45
|
+
)
|
|
46
|
+
from .version import __version__
|
|
47
|
+
|
|
48
|
+
VERSION = __version__
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"VERSION",
|
|
52
|
+
"AsyncClient",
|
|
53
|
+
"BILLING_BLOCKED_MESSAGE",
|
|
54
|
+
"BlockedError",
|
|
55
|
+
"CONCURRENCY_LIMITED_MESSAGE",
|
|
56
|
+
"Client",
|
|
57
|
+
"Configuration",
|
|
58
|
+
"ConfigurationError",
|
|
59
|
+
"Decision",
|
|
60
|
+
"DeniedError",
|
|
61
|
+
"Error",
|
|
62
|
+
"ErrorInfo",
|
|
63
|
+
"NetworkError",
|
|
64
|
+
"RATE_LIMITED_MESSAGE",
|
|
65
|
+
"RateLimitedError",
|
|
66
|
+
"RejectedError",
|
|
67
|
+
"TimeoutError",
|
|
68
|
+
"UNAVAILABLE_MESSAGE",
|
|
69
|
+
"UnavailableError",
|
|
70
|
+
"__version__",
|
|
71
|
+
"client",
|
|
72
|
+
"configure",
|
|
73
|
+
"configuration",
|
|
74
|
+
"deny_response",
|
|
75
|
+
"idempotency_key_for",
|
|
76
|
+
"plan_denied_message",
|
|
77
|
+
"rejected_message",
|
|
78
|
+
"reset",
|
|
79
|
+
"reset_day_utc",
|
|
80
|
+
"test_limit_message",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Configuration:
|
|
85
|
+
"""Global settings for ordainer.client(), same fields as Client."""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self.api_key: Optional[str] = os.environ.get("ORDAINER_API_KEY") or os.environ.get("ORDAINER_KEY")
|
|
89
|
+
self.base_url: Optional[str] = None
|
|
90
|
+
self.timeout: Any = None
|
|
91
|
+
self.fail_mode: Any = None
|
|
92
|
+
self.retries: Any = None
|
|
93
|
+
self.retry_budget: Any = None
|
|
94
|
+
self.mode: Any = None
|
|
95
|
+
self.billing_lapse: Any = None
|
|
96
|
+
self.deny_status: Any = None
|
|
97
|
+
self.sticky_deny: Any = None
|
|
98
|
+
self.logger: Any = None
|
|
99
|
+
self.on_decision: Optional[Callable[..., Any]] = None
|
|
100
|
+
|
|
101
|
+
def to_client_options(self) -> dict[str, Any]:
|
|
102
|
+
options = {
|
|
103
|
+
"api_key": self.api_key,
|
|
104
|
+
"base_url": self.base_url,
|
|
105
|
+
"timeout": self.timeout,
|
|
106
|
+
"fail_mode": self.fail_mode,
|
|
107
|
+
"retries": self.retries,
|
|
108
|
+
"retry_budget": self.retry_budget,
|
|
109
|
+
"mode": self.mode,
|
|
110
|
+
"billing_lapse": self.billing_lapse,
|
|
111
|
+
"deny_status": self.deny_status,
|
|
112
|
+
"sticky_deny": self.sticky_deny,
|
|
113
|
+
"logger": self.logger,
|
|
114
|
+
"on_decision": self.on_decision,
|
|
115
|
+
}
|
|
116
|
+
return {key: value for key, value in options.items() if value is not None}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_configuration: Optional[Configuration] = None
|
|
120
|
+
_client: Optional[Client] = None
|
|
121
|
+
_lock = threading.RLock()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def configuration() -> Configuration:
|
|
125
|
+
global _configuration
|
|
126
|
+
with _lock:
|
|
127
|
+
if _configuration is None:
|
|
128
|
+
_configuration = Configuration()
|
|
129
|
+
return _configuration
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def configure(callback: Callable[[Configuration], Any]) -> Configuration:
|
|
133
|
+
"""Apply callback to the global configuration and drop the cached client."""
|
|
134
|
+
global _client
|
|
135
|
+
cfg = configuration()
|
|
136
|
+
callback(cfg)
|
|
137
|
+
with _lock:
|
|
138
|
+
old = _client
|
|
139
|
+
_client = None
|
|
140
|
+
if old is not None:
|
|
141
|
+
old.close()
|
|
142
|
+
return cfg
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def client() -> Client:
|
|
146
|
+
"""A shared client built from the configuration."""
|
|
147
|
+
global _client
|
|
148
|
+
with _lock:
|
|
149
|
+
if _client is None:
|
|
150
|
+
_client = Client(**configuration().to_client_options())
|
|
151
|
+
return _client
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def reset() -> None:
|
|
155
|
+
"""Drop the global configuration and the shared client."""
|
|
156
|
+
global _configuration, _client
|
|
157
|
+
with _lock:
|
|
158
|
+
old = _client
|
|
159
|
+
_configuration = None
|
|
160
|
+
_client = None
|
|
161
|
+
if old is not None:
|
|
162
|
+
old.close()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def __getattr__(name: str) -> Any:
|
|
166
|
+
if name == "AsyncClient":
|
|
167
|
+
from .async_client import AsyncClient
|
|
168
|
+
|
|
169
|
+
return AsyncClient
|
|
170
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
ordainer/async_client.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Optional async client. Requires httpx (`pip install ordainer[async]`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Awaitable, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from .client import USER_AGENT, _default_logger, random_default
|
|
8
|
+
from .decision import Decision
|
|
9
|
+
from .engine import (
|
|
10
|
+
DEFAULT_BASE_URL,
|
|
11
|
+
DEFAULT_RETRIES,
|
|
12
|
+
DEFAULT_RETRY_BUDGET,
|
|
13
|
+
DEFAULT_STICKY_DENY,
|
|
14
|
+
DEFAULT_TIMEOUT,
|
|
15
|
+
FAIL_MODES,
|
|
16
|
+
MODES,
|
|
17
|
+
Runtime,
|
|
18
|
+
check_choice,
|
|
19
|
+
check_deny_status,
|
|
20
|
+
check_retries,
|
|
21
|
+
check_retry_budget,
|
|
22
|
+
check_sticky,
|
|
23
|
+
check_timeout,
|
|
24
|
+
strip_base_url,
|
|
25
|
+
)
|
|
26
|
+
from .errors import ConfigurationError, DeniedError, RateLimitedError, RejectedError, UnavailableError
|
|
27
|
+
from .transport import Transport
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _require_httpx() -> Any:
|
|
31
|
+
try:
|
|
32
|
+
import httpx
|
|
33
|
+
except ImportError as exc:
|
|
34
|
+
raise ImportError("AsyncClient requires httpx. Install it with: pip install 'ordainer[async]'") from exc
|
|
35
|
+
return httpx
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncClient:
|
|
39
|
+
"""Async twin of Client. Same decide() rules, httpx for the HTTP call.
|
|
40
|
+
|
|
41
|
+
Use as an async context manager, or call aclose() when finished.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
api_key: Optional[str] = None,
|
|
48
|
+
base_url: Optional[str] = None,
|
|
49
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
50
|
+
fail_mode: str = "open",
|
|
51
|
+
retries: int = DEFAULT_RETRIES,
|
|
52
|
+
retry_budget: float = DEFAULT_RETRY_BUDGET,
|
|
53
|
+
mode: Optional[str] = None,
|
|
54
|
+
billing_lapse: str = "open",
|
|
55
|
+
deny_status: int = 429,
|
|
56
|
+
sticky_deny: float = DEFAULT_STICKY_DENY,
|
|
57
|
+
logger: Any = None,
|
|
58
|
+
on_decision: Optional[Callable[[Decision], Any]] = None,
|
|
59
|
+
sleeper: Optional[Callable[[float], Any]] = None,
|
|
60
|
+
random: Optional[Callable[[], float]] = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._httpx = _require_httpx()
|
|
63
|
+
import os
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
if api_key is None:
|
|
67
|
+
api_key = os.environ.get("ORDAINER_API_KEY") or os.environ.get("ORDAINER_KEY")
|
|
68
|
+
if base_url is None:
|
|
69
|
+
base_url = os.environ.get("ORDAINER_BASE_URL") or DEFAULT_BASE_URL
|
|
70
|
+
self.base_url = strip_base_url(str(base_url))
|
|
71
|
+
self.timeout = check_timeout(timeout)
|
|
72
|
+
self.fail_mode = check_choice(fail_mode, FAIL_MODES, "fail_mode")
|
|
73
|
+
self.billing_lapse = check_choice(billing_lapse, FAIL_MODES, "billing_lapse")
|
|
74
|
+
self.mode = None if mode is None else check_choice(mode, MODES, "mode")
|
|
75
|
+
self.retries = check_retries(retries)
|
|
76
|
+
self.retry_budget = check_retry_budget(retry_budget)
|
|
77
|
+
self.deny_status = check_deny_status(deny_status)
|
|
78
|
+
self.sticky_deny = check_sticky(sticky_deny)
|
|
79
|
+
except ConfigurationError:
|
|
80
|
+
raise
|
|
81
|
+
except (TypeError, ValueError) as exc:
|
|
82
|
+
raise ConfigurationError(str(exc)) from exc
|
|
83
|
+
self.api_key = api_key
|
|
84
|
+
if logger is False:
|
|
85
|
+
active_logger = None
|
|
86
|
+
elif logger is None:
|
|
87
|
+
active_logger = _default_logger()
|
|
88
|
+
else:
|
|
89
|
+
active_logger = logger
|
|
90
|
+
self._sleeper = sleeper
|
|
91
|
+
# Transport parses the URL (host, path) the same way as the sync client. No connection is kept here.
|
|
92
|
+
try:
|
|
93
|
+
parsed = Transport(self.base_url)
|
|
94
|
+
except ConfigurationError:
|
|
95
|
+
raise
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
raise ConfigurationError(str(exc)) from exc
|
|
98
|
+
self._endpoint = parsed.endpoint
|
|
99
|
+
parsed.close()
|
|
100
|
+
self._runtime = Runtime(
|
|
101
|
+
base_url=self.base_url,
|
|
102
|
+
host=parsed.host,
|
|
103
|
+
timeout=self.timeout,
|
|
104
|
+
fail_mode=self.fail_mode,
|
|
105
|
+
retries=self.retries,
|
|
106
|
+
retry_budget=self.retry_budget,
|
|
107
|
+
mode=self.mode,
|
|
108
|
+
billing_lapse=self.billing_lapse,
|
|
109
|
+
deny_status=self.deny_status,
|
|
110
|
+
sticky_deny=self.sticky_deny,
|
|
111
|
+
api_key=api_key if api_key is None else str(api_key),
|
|
112
|
+
logger=active_logger,
|
|
113
|
+
on_decision=on_decision,
|
|
114
|
+
random=random or random_default,
|
|
115
|
+
)
|
|
116
|
+
self._http: Any = None
|
|
117
|
+
|
|
118
|
+
async def _client(self) -> Any:
|
|
119
|
+
if self._http is None:
|
|
120
|
+
timeout = self._httpx.Timeout(self.timeout)
|
|
121
|
+
self._http = self._httpx.AsyncClient(timeout=timeout, follow_redirects=False, trust_env=False)
|
|
122
|
+
return self._http
|
|
123
|
+
|
|
124
|
+
async def aclose(self) -> None:
|
|
125
|
+
http = self._http
|
|
126
|
+
self._http = None
|
|
127
|
+
if http is not None:
|
|
128
|
+
await http.aclose()
|
|
129
|
+
|
|
130
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
134
|
+
await self.aclose()
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
return f"<ordainer.AsyncClient base_url={self.base_url!r} fail_mode={self.fail_mode!r}>"
|
|
138
|
+
|
|
139
|
+
async def _post(self, api_key: str, payload: bytes, timeout: float) -> tuple[int, dict[str, str], bytes]:
|
|
140
|
+
http = await self._client()
|
|
141
|
+
try:
|
|
142
|
+
response = await http.post(
|
|
143
|
+
self._endpoint,
|
|
144
|
+
content=payload,
|
|
145
|
+
headers={
|
|
146
|
+
"Authorization": f"Bearer {api_key}",
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
"User-Agent": USER_AGENT,
|
|
149
|
+
"Accept": "application/json",
|
|
150
|
+
},
|
|
151
|
+
timeout=self._httpx.Timeout(timeout),
|
|
152
|
+
)
|
|
153
|
+
except self._httpx.TimeoutException:
|
|
154
|
+
raise
|
|
155
|
+
except self._httpx.HTTPError:
|
|
156
|
+
raise
|
|
157
|
+
headers = {key.lower(): value for key, value in response.headers.items()}
|
|
158
|
+
return response.status_code, headers, response.content
|
|
159
|
+
|
|
160
|
+
async def _pause(self, seconds: float) -> None:
|
|
161
|
+
sleeper = self._sleeper
|
|
162
|
+
if sleeper is None:
|
|
163
|
+
import asyncio
|
|
164
|
+
|
|
165
|
+
await asyncio.sleep(seconds)
|
|
166
|
+
return
|
|
167
|
+
result = sleeper(seconds)
|
|
168
|
+
if _is_awaitable(result):
|
|
169
|
+
await result
|
|
170
|
+
|
|
171
|
+
async def decide(
|
|
172
|
+
self,
|
|
173
|
+
*,
|
|
174
|
+
tool: Any,
|
|
175
|
+
api_key: Optional[str] = None,
|
|
176
|
+
idempotency_key: Any = None,
|
|
177
|
+
mode: Optional[str] = None,
|
|
178
|
+
fail_mode: Optional[str] = None,
|
|
179
|
+
billing_lapse: Optional[str] = None,
|
|
180
|
+
retries: Optional[int] = None,
|
|
181
|
+
) -> Decision:
|
|
182
|
+
"""Ask Ordainer whether this call may run. Never raises for an answer or an outage."""
|
|
183
|
+
plan = self._runtime.decide_plan(
|
|
184
|
+
tool=tool,
|
|
185
|
+
api_key=api_key,
|
|
186
|
+
idempotency_key=idempotency_key,
|
|
187
|
+
mode=mode,
|
|
188
|
+
fail_mode=fail_mode,
|
|
189
|
+
billing_lapse=billing_lapse,
|
|
190
|
+
retries=retries,
|
|
191
|
+
)
|
|
192
|
+
if plan["done"] is not None:
|
|
193
|
+
return self._runtime.finish(plan["done"], plan["started"])
|
|
194
|
+
timeout = self.timeout
|
|
195
|
+
attempt = 0
|
|
196
|
+
result: Decision
|
|
197
|
+
while True:
|
|
198
|
+
attempt += 1
|
|
199
|
+
try:
|
|
200
|
+
status, headers, raw = await self._post(plan["key"], plan["payload"], timeout)
|
|
201
|
+
except self._httpx.TimeoutException:
|
|
202
|
+
result, retryable = self._runtime.on_timeout(plan, timeout)
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
# Same contract as the sync client: an encoding or transport error is a
|
|
205
|
+
# network_error, and fail_mode applies. decide() does not raise.
|
|
206
|
+
result, retryable = self._runtime.on_network(plan, exc)
|
|
207
|
+
else:
|
|
208
|
+
result, retryable = self._runtime.on_http(plan, status, headers, raw)
|
|
209
|
+
result.attempts = attempt
|
|
210
|
+
if not retryable or attempt >= plan["max_attempts"]:
|
|
211
|
+
break
|
|
212
|
+
nxt = self._runtime.pause_for(attempt, plan["budget_end"])
|
|
213
|
+
if nxt is None:
|
|
214
|
+
break
|
|
215
|
+
pause, timeout = nxt
|
|
216
|
+
await self._pause(pause)
|
|
217
|
+
result = self._runtime.apply_sticky(result, plan["key"], plan["tool"], plan["req_mode"])
|
|
218
|
+
return self._runtime.finish(result, plan["started"])
|
|
219
|
+
|
|
220
|
+
async def decide_or_raise(self, **kwargs: Any) -> Decision:
|
|
221
|
+
"""Like decide, but raises a BlockedError subclass when the call must not run."""
|
|
222
|
+
decision = await self.decide(**kwargs)
|
|
223
|
+
if decision.allowed:
|
|
224
|
+
return decision
|
|
225
|
+
if decision.rate_limited:
|
|
226
|
+
raise RateLimitedError(decision)
|
|
227
|
+
if decision.rejected:
|
|
228
|
+
raise RejectedError(decision)
|
|
229
|
+
if decision.fail_closed:
|
|
230
|
+
raise UnavailableError(decision)
|
|
231
|
+
raise DeniedError(decision)
|
|
232
|
+
|
|
233
|
+
async def guard(self, fn: Callable[[Decision], Any], /, **kwargs: Any) -> Any:
|
|
234
|
+
"""Run fn(decision) only if the call is allowed. Raises like decide_or_raise."""
|
|
235
|
+
decision = await self.decide_or_raise(**kwargs)
|
|
236
|
+
result = fn(decision)
|
|
237
|
+
if _is_awaitable(result):
|
|
238
|
+
return await result
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _is_awaitable(value: Any) -> bool:
|
|
243
|
+
return isinstance(value, Awaitable)
|
ordainer/client.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Synchronous Ordainer client. Standard library only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Callable, Optional
|
|
11
|
+
|
|
12
|
+
from .decision import Decision
|
|
13
|
+
from .engine import (
|
|
14
|
+
DEFAULT_BASE_URL,
|
|
15
|
+
DEFAULT_RETRIES,
|
|
16
|
+
DEFAULT_RETRY_BUDGET,
|
|
17
|
+
DEFAULT_STICKY_DENY,
|
|
18
|
+
DEFAULT_TIMEOUT,
|
|
19
|
+
Runtime,
|
|
20
|
+
check_choice,
|
|
21
|
+
check_deny_status,
|
|
22
|
+
check_retries,
|
|
23
|
+
check_retry_budget,
|
|
24
|
+
check_sticky,
|
|
25
|
+
check_timeout,
|
|
26
|
+
strip_base_url,
|
|
27
|
+
FAIL_MODES,
|
|
28
|
+
MODES,
|
|
29
|
+
)
|
|
30
|
+
from .errors import ConfigurationError, DeniedError, RateLimitedError, RejectedError, UnavailableError
|
|
31
|
+
from .transport import Transport, is_timeout
|
|
32
|
+
from .version import __version__
|
|
33
|
+
|
|
34
|
+
USER_AGENT = f"ordainer-python/{__version__}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _default_logger() -> logging.Logger:
|
|
38
|
+
logger = logging.Logger("ordainer")
|
|
39
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
40
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
41
|
+
logger.addHandler(handler)
|
|
42
|
+
logger.setLevel(logging.WARNING)
|
|
43
|
+
return logger
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Client:
|
|
47
|
+
"""Thread-safe. Share one per process.
|
|
48
|
+
|
|
49
|
+
decide() never raises for an Ordainer answer or an outage. A deny is a normal Decision.
|
|
50
|
+
Bad arguments raise ConfigurationError.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
api_key: Optional[str] = None,
|
|
57
|
+
base_url: Optional[str] = None,
|
|
58
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
59
|
+
fail_mode: str = "open",
|
|
60
|
+
retries: int = DEFAULT_RETRIES,
|
|
61
|
+
retry_budget: float = DEFAULT_RETRY_BUDGET,
|
|
62
|
+
mode: Optional[str] = None,
|
|
63
|
+
billing_lapse: str = "open",
|
|
64
|
+
deny_status: int = 429,
|
|
65
|
+
sticky_deny: float = DEFAULT_STICKY_DENY,
|
|
66
|
+
logger: Any = None,
|
|
67
|
+
on_decision: Optional[Callable[[Decision], Any]] = None,
|
|
68
|
+
sleeper: Optional[Callable[[float], Any]] = None,
|
|
69
|
+
random: Optional[Callable[[], float]] = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
try:
|
|
72
|
+
if api_key is None:
|
|
73
|
+
api_key = os.environ.get("ORDAINER_API_KEY") or os.environ.get("ORDAINER_KEY")
|
|
74
|
+
if base_url is None:
|
|
75
|
+
base_url = os.environ.get("ORDAINER_BASE_URL") or DEFAULT_BASE_URL
|
|
76
|
+
self.base_url = strip_base_url(str(base_url))
|
|
77
|
+
self.timeout = check_timeout(timeout)
|
|
78
|
+
self.fail_mode = check_choice(fail_mode, FAIL_MODES, "fail_mode")
|
|
79
|
+
self.billing_lapse = check_choice(billing_lapse, FAIL_MODES, "billing_lapse")
|
|
80
|
+
self.mode = None if mode is None else check_choice(mode, MODES, "mode")
|
|
81
|
+
self.retries = check_retries(retries)
|
|
82
|
+
self.retry_budget = check_retry_budget(retry_budget)
|
|
83
|
+
self.deny_status = check_deny_status(deny_status)
|
|
84
|
+
self.sticky_deny = check_sticky(sticky_deny)
|
|
85
|
+
except ConfigurationError:
|
|
86
|
+
raise
|
|
87
|
+
except (TypeError, ValueError) as exc:
|
|
88
|
+
raise ConfigurationError(str(exc)) from exc
|
|
89
|
+
self.api_key = api_key
|
|
90
|
+
if logger is False:
|
|
91
|
+
active_logger = None
|
|
92
|
+
elif logger is None:
|
|
93
|
+
active_logger = _default_logger()
|
|
94
|
+
else:
|
|
95
|
+
active_logger = logger
|
|
96
|
+
self._sleeper = sleeper or time.sleep
|
|
97
|
+
try:
|
|
98
|
+
self._transport = Transport(self.base_url)
|
|
99
|
+
except ConfigurationError:
|
|
100
|
+
raise
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
raise ConfigurationError(str(exc)) from exc
|
|
103
|
+
self._runtime = Runtime(
|
|
104
|
+
base_url=self.base_url,
|
|
105
|
+
host=self._transport.host,
|
|
106
|
+
timeout=self.timeout,
|
|
107
|
+
fail_mode=self.fail_mode,
|
|
108
|
+
retries=self.retries,
|
|
109
|
+
retry_budget=self.retry_budget,
|
|
110
|
+
mode=self.mode,
|
|
111
|
+
billing_lapse=self.billing_lapse,
|
|
112
|
+
deny_status=self.deny_status,
|
|
113
|
+
sticky_deny=self.sticky_deny,
|
|
114
|
+
api_key=api_key if api_key is None else str(api_key),
|
|
115
|
+
logger=active_logger,
|
|
116
|
+
on_decision=on_decision,
|
|
117
|
+
random=random or random_default,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def decide(
|
|
121
|
+
self,
|
|
122
|
+
*,
|
|
123
|
+
tool: Any,
|
|
124
|
+
api_key: Optional[str] = None,
|
|
125
|
+
idempotency_key: Any = None,
|
|
126
|
+
mode: Optional[str] = None,
|
|
127
|
+
fail_mode: Optional[str] = None,
|
|
128
|
+
billing_lapse: Optional[str] = None,
|
|
129
|
+
retries: Optional[int] = None,
|
|
130
|
+
) -> Decision:
|
|
131
|
+
"""Ask Ordainer whether this call may run. Never raises for an answer or an outage."""
|
|
132
|
+
plan = self._runtime.decide_plan(
|
|
133
|
+
tool=tool,
|
|
134
|
+
api_key=api_key,
|
|
135
|
+
idempotency_key=idempotency_key,
|
|
136
|
+
mode=mode,
|
|
137
|
+
fail_mode=fail_mode,
|
|
138
|
+
billing_lapse=billing_lapse,
|
|
139
|
+
retries=retries,
|
|
140
|
+
)
|
|
141
|
+
if plan["done"] is not None:
|
|
142
|
+
return self._runtime.finish(plan["done"], plan["started"])
|
|
143
|
+
timeout = self.timeout
|
|
144
|
+
attempt = 0
|
|
145
|
+
result: Decision
|
|
146
|
+
while True:
|
|
147
|
+
attempt += 1
|
|
148
|
+
try:
|
|
149
|
+
status, headers, raw = self._transport.post(plan["key"], plan["payload"], timeout, USER_AGENT)
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
# decide() never raises for a transport failure. A newline or a non-Latin-1
|
|
152
|
+
# key makes http.client raise ValueError / UnicodeEncodeError before the
|
|
153
|
+
# bytes go out; that is a network_error, and fail_mode applies. Timeouts stay timeouts.
|
|
154
|
+
if is_timeout(exc):
|
|
155
|
+
result, retryable = self._runtime.on_timeout(plan, timeout)
|
|
156
|
+
else:
|
|
157
|
+
result, retryable = self._runtime.on_network(plan, exc)
|
|
158
|
+
else:
|
|
159
|
+
result, retryable = self._runtime.on_http(plan, status, headers, raw)
|
|
160
|
+
result.attempts = attempt
|
|
161
|
+
if not retryable or attempt >= plan["max_attempts"]:
|
|
162
|
+
break
|
|
163
|
+
nxt = self._runtime.pause_for(attempt, plan["budget_end"])
|
|
164
|
+
if nxt is None:
|
|
165
|
+
break
|
|
166
|
+
pause, timeout = nxt
|
|
167
|
+
self._sleeper(pause)
|
|
168
|
+
result = self._runtime.apply_sticky(result, plan["key"], plan["tool"], plan["req_mode"])
|
|
169
|
+
return self._runtime.finish(result, plan["started"])
|
|
170
|
+
|
|
171
|
+
def decide_or_raise(self, **kwargs: Any) -> Decision:
|
|
172
|
+
"""Like decide, but raises a BlockedError subclass when the call must not run.
|
|
173
|
+
|
|
174
|
+
This is the Python name for the Ruby client's decide!.
|
|
175
|
+
"""
|
|
176
|
+
decision = self.decide(**kwargs)
|
|
177
|
+
if decision.allowed:
|
|
178
|
+
return decision
|
|
179
|
+
if decision.rate_limited:
|
|
180
|
+
raise RateLimitedError(decision)
|
|
181
|
+
if decision.rejected:
|
|
182
|
+
raise RejectedError(decision)
|
|
183
|
+
if decision.fail_closed:
|
|
184
|
+
raise UnavailableError(decision)
|
|
185
|
+
raise DeniedError(decision)
|
|
186
|
+
|
|
187
|
+
def guard(self, fn: Callable[[Decision], Any], /, **kwargs: Any) -> Any:
|
|
188
|
+
"""Run fn(decision) only if the call is allowed. Raises like decide_or_raise."""
|
|
189
|
+
decision = self.decide_or_raise(**kwargs)
|
|
190
|
+
return fn(decision)
|
|
191
|
+
|
|
192
|
+
def close(self) -> None:
|
|
193
|
+
self._transport.close()
|
|
194
|
+
|
|
195
|
+
def __repr__(self) -> str:
|
|
196
|
+
# Never shows the key.
|
|
197
|
+
return f"<ordainer.Client base_url={self.base_url!r} fail_mode={self.fail_mode!r}>"
|
|
198
|
+
|
|
199
|
+
def __enter__(self) -> "Client":
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
def __exit__(self, *exc: Any) -> None:
|
|
203
|
+
self.close()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def random_default() -> float:
|
|
207
|
+
return random.random()
|