slen 0.4.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.
Files changed (61) hide show
  1. slen/__init__.py +160 -0
  2. slen/_client.py +244 -0
  3. slen/_config.py +117 -0
  4. slen/_errors.py +176 -0
  5. slen/_health.py +43 -0
  6. slen/_http/__init__.py +0 -0
  7. slen/_http/_answer.py +51 -0
  8. slen/_http/_async.py +102 -0
  9. slen/_http/_clock.py +19 -0
  10. slen/_http/_endpoints.py +29 -0
  11. slen/_http/_envelope.py +117 -0
  12. slen/_http/_error_map.py +115 -0
  13. slen/_http/_fields.py +134 -0
  14. slen/_http/_journal.py +47 -0
  15. slen/_http/_pool.py +13 -0
  16. slen/_http/_request.py +91 -0
  17. slen/_http/_retry.py +85 -0
  18. slen/_http/_sync.py +117 -0
  19. slen/_http/_wire.py +102 -0
  20. slen/_idempotency.py +17 -0
  21. slen/_logger.py +71 -0
  22. slen/_modules/__init__.py +0 -0
  23. slen/_modules/_context.py +18 -0
  24. slen/_modules/_pages.py +65 -0
  25. slen/_modules/customers/__init__.py +0 -0
  26. slen/_modules/customers/_async.py +318 -0
  27. slen/_modules/customers/_calls.py +193 -0
  28. slen/_modules/customers/_read.py +106 -0
  29. slen/_modules/customers/_sync.py +318 -0
  30. slen/_modules/customers/_types.py +101 -0
  31. slen/_modules/executions/__init__.py +0 -0
  32. slen/_modules/executions/_async.py +97 -0
  33. slen/_modules/executions/_calls.py +17 -0
  34. slen/_modules/executions/_read.py +118 -0
  35. slen/_modules/executions/_sync.py +93 -0
  36. slen/_modules/executions/_types.py +160 -0
  37. slen/_modules/executions/_wait.py +127 -0
  38. slen/_modules/operations/__init__.py +0 -0
  39. slen/_modules/operations/_async.py +88 -0
  40. slen/_modules/operations/_calls.py +85 -0
  41. slen/_modules/operations/_read.py +33 -0
  42. slen/_modules/operations/_sync.py +88 -0
  43. slen/_modules/operations/_types.py +46 -0
  44. slen/_modules/webhooks/__init__.py +0 -0
  45. slen/_modules/webhooks/_events.py +138 -0
  46. slen/_modules/webhooks/_signature.py +72 -0
  47. slen/_modules/webhooks/_types.py +137 -0
  48. slen/_modules/webhooks/_webhooks.py +149 -0
  49. slen/_observability/__init__.py +0 -0
  50. slen/_observability/_recorders.py +50 -0
  51. slen/_observability/_scope.py +86 -0
  52. slen/_observability/_span.py +36 -0
  53. slen/_page.py +13 -0
  54. slen/_version.py +3 -0
  55. slen/otel.py +36 -0
  56. slen/py.typed +0 -0
  57. slen/testing.py +152 -0
  58. slen-0.4.0.dist-info/METADATA +341 -0
  59. slen-0.4.0.dist-info/RECORD +61 -0
  60. slen-0.4.0.dist-info/WHEEL +4 -0
  61. slen-0.4.0.dist-info/licenses/LICENSE +21 -0
slen/__init__.py ADDED
@@ -0,0 +1,160 @@
1
+ """The Slen Python SDK: run flows and read their outcome, keep end customers and their credits, check webhooks."""
2
+
3
+ from slen._client import AsyncSlen, Slen
4
+ from slen._errors import (
5
+ AuthenticationError,
6
+ BudgetExceededError,
7
+ ConcurrencyLimitError,
8
+ ConnectionError,
9
+ CustomerBlockedError,
10
+ CustomerNotFoundError,
11
+ FlowRuntimeError,
12
+ IdempotencyConflictError,
13
+ InsufficientCreditsError,
14
+ InvalidRequestError,
15
+ InvalidResponseError,
16
+ OperationLimitExceededError,
17
+ OperationUncertainError,
18
+ OverrunApprovalRequiredError,
19
+ OverrunStoppedError,
20
+ PolicyDeniedError,
21
+ ProviderError,
22
+ RateLimitError,
23
+ ServiceUnavailableError,
24
+ SlenError,
25
+ SpendRefusedError,
26
+ TimeoutError,
27
+ WebhookSignatureError,
28
+ WebhookSignatureReason,
29
+ )
30
+ from slen._health import Health
31
+ from slen._idempotency import generate_idempotency_key, idempotency_key_for_job
32
+ from slen._logger import LOG_EVENTS, SlenLogger
33
+ from slen._modules.customers._async import AsyncCustomers
34
+ from slen._modules.customers._sync import Customers
35
+ from slen._modules.customers._types import (
36
+ CreditEntry,
37
+ CreditEntryType,
38
+ CreditPrice,
39
+ CreditPriceList,
40
+ Customer,
41
+ CustomerAccount,
42
+ CustomerStatus,
43
+ )
44
+ from slen._modules.executions._async import AsyncExecutions
45
+ from slen._modules.executions._sync import Executions
46
+ from slen._modules.executions._types import (
47
+ AttemptSummary,
48
+ ExecutionCache,
49
+ ExecutionCost,
50
+ ExecutionError,
51
+ ExecutionStatus,
52
+ ExecutionStatusView,
53
+ ExecutionTrace,
54
+ FlowExecutionEvent,
55
+ FlowExecutionNode,
56
+ WaitingOn,
57
+ )
58
+ from slen._modules.executions._wait import AsyncPollErrorListener, PollErrorListener
59
+ from slen._modules.operations._async import AsyncOperations
60
+ from slen._modules.operations._calls import ContextValue
61
+ from slen._modules.operations._sync import Operations
62
+ from slen._modules.operations._types import ExecutionResult
63
+ from slen._modules.webhooks._types import (
64
+ ApprovalDecidedEvent,
65
+ ApprovalDecidedEventData,
66
+ ApprovalRequestedEvent,
67
+ ApprovalRequestedEventData,
68
+ CustomerBalanceLowEvent,
69
+ CustomerBalanceLowEventData,
70
+ ExecutionFinishedEvent,
71
+ ExecutionFinishedEventData,
72
+ UnknownWebhookEvent,
73
+ WebhookEvent,
74
+ WebhookEventType,
75
+ WebhookPayload,
76
+ )
77
+ from slen._modules.webhooks._webhooks import Webhooks
78
+ from slen._observability._recorders import LoggerSpanRecorder, SpanRecorder
79
+ from slen._observability._span import SlenSpan
80
+ from slen._page import Page
81
+ from slen._version import __version__
82
+
83
+ __all__ = (
84
+ "LOG_EVENTS",
85
+ "ApprovalDecidedEvent",
86
+ "ApprovalDecidedEventData",
87
+ "ApprovalRequestedEvent",
88
+ "ApprovalRequestedEventData",
89
+ "AsyncCustomers",
90
+ "AsyncExecutions",
91
+ "AsyncOperations",
92
+ "AsyncPollErrorListener",
93
+ "AsyncSlen",
94
+ "AttemptSummary",
95
+ "AuthenticationError",
96
+ "BudgetExceededError",
97
+ "ConcurrencyLimitError",
98
+ "ConnectionError",
99
+ "ContextValue",
100
+ "CreditEntry",
101
+ "CreditEntryType",
102
+ "CreditPrice",
103
+ "CreditPriceList",
104
+ "Customer",
105
+ "CustomerAccount",
106
+ "CustomerBalanceLowEvent",
107
+ "CustomerBalanceLowEventData",
108
+ "CustomerBlockedError",
109
+ "CustomerNotFoundError",
110
+ "CustomerStatus",
111
+ "Customers",
112
+ "ExecutionCache",
113
+ "ExecutionCost",
114
+ "ExecutionError",
115
+ "ExecutionFinishedEvent",
116
+ "ExecutionFinishedEventData",
117
+ "ExecutionResult",
118
+ "ExecutionStatus",
119
+ "ExecutionStatusView",
120
+ "ExecutionTrace",
121
+ "Executions",
122
+ "FlowExecutionEvent",
123
+ "FlowExecutionNode",
124
+ "FlowRuntimeError",
125
+ "Health",
126
+ "IdempotencyConflictError",
127
+ "InsufficientCreditsError",
128
+ "InvalidRequestError",
129
+ "InvalidResponseError",
130
+ "LoggerSpanRecorder",
131
+ "OperationLimitExceededError",
132
+ "OperationUncertainError",
133
+ "Operations",
134
+ "OverrunApprovalRequiredError",
135
+ "OverrunStoppedError",
136
+ "Page",
137
+ "PolicyDeniedError",
138
+ "PollErrorListener",
139
+ "ProviderError",
140
+ "RateLimitError",
141
+ "ServiceUnavailableError",
142
+ "Slen",
143
+ "SlenError",
144
+ "SlenLogger",
145
+ "SlenSpan",
146
+ "SpanRecorder",
147
+ "SpendRefusedError",
148
+ "TimeoutError",
149
+ "UnknownWebhookEvent",
150
+ "WaitingOn",
151
+ "WebhookEvent",
152
+ "WebhookEventType",
153
+ "WebhookPayload",
154
+ "WebhookSignatureError",
155
+ "WebhookSignatureReason",
156
+ "Webhooks",
157
+ "__version__",
158
+ "generate_idempotency_key",
159
+ "idempotency_key_for_job",
160
+ )
slen/_client.py ADDED
@@ -0,0 +1,244 @@
1
+ import dataclasses
2
+ from collections.abc import Mapping
3
+ from dataclasses import dataclass
4
+ from typing import Generic, NoReturn, Self, TypeVar
5
+
6
+ import httpx
7
+
8
+ from slen._config import DEFAULT_BASE_URL, ClientConfig, ClientOptions, logger_of, resolve_config
9
+ from slen._health import Health, health_call
10
+ from slen._http import _clock, _pool
11
+ from slen._http._async import AsyncTransport
12
+ from slen._http._clock import Clock
13
+ from slen._http._request import Sending
14
+ from slen._http._sync import SyncTransport
15
+ from slen._logger import SlenLogger
16
+ from slen._modules._context import AsyncContext, SyncContext
17
+ from slen._modules.customers._async import AsyncCustomers
18
+ from slen._modules.customers._sync import Customers
19
+ from slen._modules.executions._async import AsyncExecutions
20
+ from slen._modules.executions._sync import Executions
21
+ from slen._modules.operations._async import AsyncOperations
22
+ from slen._modules.operations._sync import Operations
23
+ from slen._modules.webhooks._webhooks import Webhooks
24
+ from slen._observability._recorders import NullSpanRecorder, SpanRecorder
25
+
26
+ C = TypeVar("C", httpx.Client, httpx.AsyncClient)
27
+
28
+
29
+ # What one client is made of; a copy made by with_logger() shares all of it but the logger, and owns no pool.
30
+ @dataclass(frozen=True, slots=True, kw_only=True)
31
+ class Parts(Generic[C]):
32
+ config: ClientConfig
33
+ http: C
34
+ owns: bool
35
+ spans: SpanRecorder
36
+ clock: Clock
37
+
38
+
39
+ def recorder_of(given: SpanRecorder | None) -> SpanRecorder:
40
+ if given is not None and not callable(getattr(given, "record", None)):
41
+ raise TypeError("Slen: span_recorder has a record(span) method.")
42
+
43
+ return NullSpanRecorder() if given is None else given
44
+
45
+
46
+ def webhooks_of(config: ClientConfig) -> Webhooks:
47
+ return Webhooks(
48
+ secret=config.webhook_secret,
49
+ tolerance_seconds=config.webhook_tolerance_seconds,
50
+ logger=config.logger,
51
+ )
52
+
53
+
54
+ def refuse_to_travel() -> NoReturn:
55
+ raise TypeError(
56
+ "Slen: a client holds connections and cannot be pickled or copied; build a client in each process, "
57
+ "and use with_logger() for a copy within one."
58
+ )
59
+
60
+
61
+ class Slen:
62
+ """The Slen client: runs flows, reads their runs, keeps end customers and their credits."""
63
+
64
+ __slots__ = ("_parts", "_transport", "customers", "executions", "operations", "webhooks")
65
+
66
+ operations: Operations
67
+ executions: Executions
68
+ customers: Customers
69
+ webhooks: Webhooks
70
+
71
+ def __init__(
72
+ self,
73
+ *,
74
+ api_key: str | None = None,
75
+ timeout: float = 65.0,
76
+ connect_timeout: float = 5.0,
77
+ retries: int = 2,
78
+ logger: SlenLogger | None = None,
79
+ span_recorder: SpanRecorder | None = None,
80
+ webhook_secret: str | None = None,
81
+ webhook_tolerance_seconds: int = 300,
82
+ http_client: httpx.Client | None = None,
83
+ headers: Mapping[str, str] | None = None,
84
+ base_url: str = DEFAULT_BASE_URL,
85
+ ) -> None:
86
+ options = ClientOptions(
87
+ api_key=api_key,
88
+ timeout=timeout,
89
+ connect_timeout=connect_timeout,
90
+ retries=retries,
91
+ logger=logger,
92
+ webhook_secret=webhook_secret,
93
+ webhook_tolerance_seconds=webhook_tolerance_seconds,
94
+ headers=headers,
95
+ base_url=base_url,
96
+ )
97
+ config = resolve_config(options)
98
+ spans = recorder_of(span_recorder)
99
+
100
+ if http_client is not None and not isinstance(http_client, httpx.Client):
101
+ raise TypeError("Slen: http_client is an httpx.Client; AsyncSlen takes an httpx.AsyncClient.")
102
+
103
+ http = _pool.sync_pool(config) if http_client is None else http_client
104
+ self._build(Parts(config=config, http=http, owns=http_client is None, spans=spans, clock=_clock.SYSTEM_CLOCK))
105
+
106
+ def _build(self, parts: Parts[httpx.Client]) -> None:
107
+ self._parts = parts
108
+ self._transport = SyncTransport(parts.config, parts.http, parts.clock)
109
+ context = SyncContext(self._transport, parts.spans)
110
+ self.operations = Operations(context)
111
+ self.executions = Executions(context)
112
+ self.customers = Customers(context)
113
+ self.webhooks = webhooks_of(parts.config)
114
+
115
+ def with_logger(self, logger: SlenLogger) -> "Slen":
116
+ """A client like this one that writes its journal to logger, over the same connections and span recorder."""
117
+ parts = self._parts
118
+ copy = type(self).__new__(type(self))
119
+ config = dataclasses.replace(parts.config, logger=logger_of(logger))
120
+ copy._build(dataclasses.replace(parts, config=config, owns=False))
121
+
122
+ return copy
123
+
124
+ def health(
125
+ self,
126
+ *,
127
+ timeout: float | None = None,
128
+ retries: int | None = None,
129
+ request_id: str | None = None,
130
+ ) -> Health:
131
+ """Liveness of the API: a dependency that is down is named in the body, not in the status."""
132
+ sending = Sending(timeout=timeout, retries=retries, request_id=request_id)
133
+
134
+ return self._transport.call(health_call(sending))
135
+
136
+ def close(self) -> None:
137
+ """Closes the connections of the HTTP client the SDK built; one passed as http_client is left to its owner."""
138
+ if self._parts.owns:
139
+ self._parts.http.close()
140
+
141
+ def __enter__(self) -> Self:
142
+ return self
143
+
144
+ def __exit__(self, *exc: object) -> None:
145
+ self.close()
146
+
147
+ def __repr__(self) -> str:
148
+ return f"Slen(timeout={self._parts.config.timeout!r}, retries={self._parts.config.retries!r})"
149
+
150
+ def __reduce__(self) -> NoReturn:
151
+ refuse_to_travel()
152
+
153
+
154
+ class AsyncSlen:
155
+ """The Slen client for asyncio: runs flows, reads their runs, keeps end customers and their credits."""
156
+
157
+ __slots__ = ("_parts", "_transport", "customers", "executions", "operations", "webhooks")
158
+
159
+ operations: AsyncOperations
160
+ executions: AsyncExecutions
161
+ customers: AsyncCustomers
162
+ webhooks: Webhooks
163
+
164
+ def __init__(
165
+ self,
166
+ *,
167
+ api_key: str | None = None,
168
+ timeout: float = 65.0,
169
+ connect_timeout: float = 5.0,
170
+ retries: int = 2,
171
+ logger: SlenLogger | None = None,
172
+ span_recorder: SpanRecorder | None = None,
173
+ webhook_secret: str | None = None,
174
+ webhook_tolerance_seconds: int = 300,
175
+ http_client: httpx.AsyncClient | None = None,
176
+ headers: Mapping[str, str] | None = None,
177
+ base_url: str = DEFAULT_BASE_URL,
178
+ ) -> None:
179
+ options = ClientOptions(
180
+ api_key=api_key,
181
+ timeout=timeout,
182
+ connect_timeout=connect_timeout,
183
+ retries=retries,
184
+ logger=logger,
185
+ webhook_secret=webhook_secret,
186
+ webhook_tolerance_seconds=webhook_tolerance_seconds,
187
+ headers=headers,
188
+ base_url=base_url,
189
+ )
190
+ config = resolve_config(options)
191
+ spans = recorder_of(span_recorder)
192
+
193
+ if http_client is not None and not isinstance(http_client, httpx.AsyncClient):
194
+ raise TypeError("Slen: http_client is an httpx.AsyncClient; Slen takes an httpx.Client.")
195
+
196
+ http = _pool.async_pool(config) if http_client is None else http_client
197
+ self._build(Parts(config=config, http=http, owns=http_client is None, spans=spans, clock=_clock.SYSTEM_CLOCK))
198
+
199
+ def _build(self, parts: Parts[httpx.AsyncClient]) -> None:
200
+ self._parts = parts
201
+ self._transport = AsyncTransport(parts.config, parts.http, parts.clock)
202
+ context = AsyncContext(self._transport, parts.spans)
203
+ self.operations = AsyncOperations(context)
204
+ self.executions = AsyncExecutions(context)
205
+ self.customers = AsyncCustomers(context)
206
+ self.webhooks = webhooks_of(parts.config)
207
+
208
+ def with_logger(self, logger: SlenLogger) -> "AsyncSlen":
209
+ """A client like this one that writes its journal to logger, over the same connections and span recorder."""
210
+ parts = self._parts
211
+ copy = type(self).__new__(type(self))
212
+ config = dataclasses.replace(parts.config, logger=logger_of(logger))
213
+ copy._build(dataclasses.replace(parts, config=config, owns=False))
214
+
215
+ return copy
216
+
217
+ async def health(
218
+ self,
219
+ *,
220
+ timeout: float | None = None,
221
+ retries: int | None = None,
222
+ request_id: str | None = None,
223
+ ) -> Health:
224
+ """Liveness of the API: a dependency that is down is named in the body, not in the status."""
225
+ sending = Sending(timeout=timeout, retries=retries, request_id=request_id)
226
+
227
+ return await self._transport.call(health_call(sending))
228
+
229
+ async def close(self) -> None:
230
+ """Closes the connections of the HTTP client the SDK built; one passed as http_client is left to its owner."""
231
+ if self._parts.owns:
232
+ await self._parts.http.aclose()
233
+
234
+ async def __aenter__(self) -> Self:
235
+ return self
236
+
237
+ async def __aexit__(self, *exc: object) -> None:
238
+ await self.close()
239
+
240
+ def __repr__(self) -> str:
241
+ return f"AsyncSlen(timeout={self._parts.config.timeout!r}, retries={self._parts.config.retries!r})"
242
+
243
+ def __reduce__(self) -> NoReturn:
244
+ refuse_to_travel()
slen/_config.py ADDED
@@ -0,0 +1,117 @@
1
+ import os
2
+ from collections.abc import Mapping
3
+ from dataclasses import dataclass, field
4
+ from types import MappingProxyType
5
+ from typing import Final
6
+
7
+ from slen._logger import SlenLogger, default_logger, is_logger
8
+
9
+ # The hosted API, `servers[0]` of the spec. Another address is for tests and a local stack only.
10
+ DEFAULT_BASE_URL: Final = "https://api.slen.ai"
11
+
12
+ # The one variable the SDK reads.
13
+ KEY_VARIABLE: Final = "SLEN_API_KEY"
14
+
15
+
16
+ @dataclass(frozen=True, slots=True, kw_only=True)
17
+ class ClientOptions:
18
+ api_key: str | None = field(repr=False)
19
+ timeout: float
20
+ connect_timeout: float
21
+ retries: int
22
+ logger: SlenLogger | None
23
+ webhook_secret: str | None = field(repr=False)
24
+ webhook_tolerance_seconds: int
25
+ headers: Mapping[str, str] | None
26
+ base_url: str
27
+
28
+
29
+ @dataclass(frozen=True, slots=True, kw_only=True)
30
+ class ClientConfig:
31
+ api_key: str = field(repr=False)
32
+ base_url: str
33
+ timeout: float
34
+ connect_timeout: float
35
+ retries: int
36
+ headers: Mapping[str, str]
37
+ logger: SlenLogger
38
+ webhook_secret: str | None = field(repr=False)
39
+ webhook_tolerance_seconds: int
40
+
41
+
42
+ def resolve_config(options: ClientOptions) -> ClientConfig:
43
+ return ClientConfig(
44
+ api_key=api_key_of(options.api_key),
45
+ base_url=base_url_of(options.base_url),
46
+ timeout=seconds_of("timeout", options.timeout),
47
+ connect_timeout=seconds_of("connect_timeout", options.connect_timeout),
48
+ retries=whole("retries", options.retries, 0),
49
+ headers=headers_of(options.headers),
50
+ logger=logger_of(options.logger),
51
+ webhook_secret=secret_of(options.webhook_secret),
52
+ webhook_tolerance_seconds=whole("webhook_tolerance_seconds", options.webhook_tolerance_seconds, 1),
53
+ )
54
+
55
+
56
+ def api_key_of(given: str | None) -> str:
57
+ key: object = os.environ.get(KEY_VARIABLE) if given is None else given
58
+
59
+ if key is not None and not isinstance(key, str):
60
+ raise TypeError("Slen: api_key is a string.")
61
+
62
+ if key is None or not key.strip():
63
+ raise ValueError(f"Slen: an api_key is required (pass api_key or set {KEY_VARIABLE}).")
64
+
65
+ return key
66
+
67
+
68
+ def base_url_of(given: str) -> str:
69
+ if not isinstance(given, str) or not given.strip():
70
+ raise ValueError("Slen: the address of the API is a URL that is not empty.")
71
+
72
+ return given.rstrip("/")
73
+
74
+
75
+ def seconds_of(name: str, value: object) -> float:
76
+ if not isinstance(value, int | float) or isinstance(value, bool):
77
+ raise TypeError(f"Slen: {name} is a number of seconds, got {type(value).__name__}.")
78
+
79
+ # Negated on purpose: NaN is not above 0 either.
80
+ if not value > 0:
81
+ raise ValueError(f"Slen: {name} must be a number above 0, got {value!r}.")
82
+
83
+ return float(value)
84
+
85
+
86
+ def whole(name: str, value: object, least: int) -> int:
87
+ if not isinstance(value, int) or isinstance(value, bool):
88
+ raise TypeError(f"Slen: {name} is an integer, got {type(value).__name__}.")
89
+
90
+ if value < least:
91
+ raise ValueError(f"Slen: {name} must be an integer of at least {least}, got {value!r}.")
92
+
93
+ return value
94
+
95
+
96
+ def headers_of(given: Mapping[str, str] | None) -> Mapping[str, str]:
97
+ headers = dict(given or {})
98
+
99
+ if not all(isinstance(name, str) and isinstance(value, str) for name, value in headers.items()):
100
+ raise TypeError("Slen: headers is a mapping of strings to strings.")
101
+
102
+ return MappingProxyType(headers)
103
+
104
+
105
+ def logger_of(given: SlenLogger | None) -> SlenLogger:
106
+ if given is not None and not is_logger(given):
107
+ raise TypeError("Slen: logger has debug, info, warning and error, as logging.Logger has.")
108
+
109
+ return default_logger() if given is None else given
110
+
111
+
112
+ # An empty or blank secret is a variable that was not set, not a secret.
113
+ def secret_of(given: str | None) -> str | None:
114
+ if given is not None and not isinstance(given, str):
115
+ raise TypeError("Slen: webhook_secret is a string.")
116
+
117
+ return None if given is None or not given.strip() else given
slen/_errors.py ADDED
@@ -0,0 +1,176 @@
1
+ import builtins
2
+ from collections.abc import Callable, Mapping
3
+ from types import MappingProxyType
4
+ from typing import Any, Final, Literal, TypeAlias
5
+
6
+ INVALID_REQUEST: Final = "invalid_request"
7
+ INVALID_RESPONSE: Final = "invalid_response"
8
+ WEBHOOK_SIGNATURE_INVALID: Final = "webhook_signature_invalid"
9
+ WEBHOOK_PAYLOAD_INVALID: Final = "webhook_payload_invalid"
10
+
11
+ WebhookSignatureReason: TypeAlias = Literal[
12
+ "missing_header", "malformed_header", "outside_tolerance", "no_matching_signature"
13
+ ]
14
+
15
+ _STATE: Final = ("code", "status", "details", "request_id", "retry_after_seconds")
16
+
17
+
18
+ class SlenError(Exception):
19
+ """The root of every error the SDK raises."""
20
+
21
+ code: str | None
22
+ status: int | None
23
+ details: Mapping[str, Any]
24
+ request_id: str | None
25
+ retry_after_seconds: int | None
26
+
27
+ def __init__(
28
+ self,
29
+ message: str,
30
+ *,
31
+ code: str | None = None,
32
+ status: int | None = None,
33
+ details: Mapping[str, Any] | None = None,
34
+ request_id: str | None = None,
35
+ retry_after_seconds: int | None = None,
36
+ ) -> None:
37
+ super().__init__(message)
38
+ self.code = code
39
+ self.status = status
40
+ self.details = MappingProxyType(dict(details or {}))
41
+ self.request_id = request_id
42
+ self.retry_after_seconds = retry_after_seconds
43
+
44
+ def __reduce__(self) -> tuple[Callable[..., "SlenError"], tuple[object, ...]]:
45
+ state = {name: getattr(self, name) for name in _STATE}
46
+ state["details"] = dict(self.details)
47
+ state.update({name: value for name, value in vars(self).items() if name not in _STATE})
48
+
49
+ return _rebuilt, (type(self), str(self), state)
50
+
51
+
52
+ def _rebuilt(kind: type[SlenError], message: str, state: dict[str, Any]) -> SlenError:
53
+ error = kind.__new__(kind, message)
54
+ Exception.__init__(error, message)
55
+ vars(error).update({**state, "details": MappingProxyType(dict(state["details"]))})
56
+
57
+ return error
58
+
59
+
60
+ class AuthenticationError(SlenError):
61
+ """401 or 403: the key, its scope or the tenant."""
62
+
63
+
64
+ class InvalidRequestError(SlenError):
65
+ """The request was refused for its shape: 422, an unknown code of a 4xx, or a check before the wire."""
66
+
67
+
68
+ class SpendRefusedError(SlenError):
69
+ """Money said no (402); each ceiling has a subtype."""
70
+
71
+
72
+ class BudgetExceededError(SpendRefusedError):
73
+ """The project's or the organization's budget."""
74
+
75
+
76
+ class InsufficientCreditsError(SpendRefusedError):
77
+ """The end customer is out of credits."""
78
+
79
+
80
+ class OverrunStoppedError(SpendRefusedError):
81
+ """An overrun of a budget that stops the run."""
82
+
83
+
84
+ class OverrunApprovalRequiredError(SpendRefusedError):
85
+ """An overrun of a budget that waits for somebody to approve it."""
86
+
87
+
88
+ class OperationLimitExceededError(SpendRefusedError):
89
+ """One operation would cost more than a per-operation budget allows."""
90
+
91
+
92
+ class ConcurrencyLimitError(SpendRefusedError):
93
+ """A concurrency budget has every slot taken; passes when one is freed."""
94
+
95
+
96
+ class CustomerNotFoundError(SlenError):
97
+ """The project has never seen this end customer."""
98
+
99
+
100
+ class RateLimitError(SlenError):
101
+ """429: the key sent more requests than its rate limit allows."""
102
+
103
+
104
+ class PolicyDeniedError(SlenError):
105
+ """A policy of the project refused the run."""
106
+
107
+
108
+ class CustomerBlockedError(PolicyDeniedError):
109
+ """The end customer of the run is blocked, so its new runs are refused; nothing ran."""
110
+
111
+
112
+ class IdempotencyConflictError(SlenError):
113
+ """The idempotency key was used with another body, or its first request is still running."""
114
+
115
+
116
+ class ProviderError(SlenError):
117
+ """A model provider failed or could not be called; any 5xx without a known code lands here too — read code."""
118
+
119
+
120
+ class TimeoutError(SlenError, builtins.TimeoutError):
121
+ """No answer in time (no code), or a run that ran out of time on the server."""
122
+
123
+
124
+ class OperationUncertainError(SlenError):
125
+ """A provider was called and nobody knows what became of the call: the money may be spent."""
126
+
127
+
128
+ class ServiceUnavailableError(SlenError):
129
+ """503 from Slen itself: nothing was done, the request may be sent again."""
130
+
131
+
132
+ class FlowRuntimeError(SlenError):
133
+ """The flow broke while it was walked; the model calls before the break were paid for."""
134
+
135
+
136
+ class ConnectionError(SlenError, builtins.ConnectionError):
137
+ """Slen could not be reached, or the connection broke before the answer was read; no code, no status."""
138
+
139
+
140
+ class InvalidResponseError(SlenError):
141
+ """A 2xx whose body cannot be read as the answer; the call may have gone through."""
142
+
143
+
144
+ class WebhookSignatureError(SlenError):
145
+ """A webhook whose Slen-Signature was not accepted: do not act on its body; reason says why."""
146
+
147
+ reason: WebhookSignatureReason
148
+
149
+ def __init__(
150
+ self,
151
+ message: str,
152
+ *,
153
+ reason: WebhookSignatureReason,
154
+ code: str | None = WEBHOOK_SIGNATURE_INVALID,
155
+ status: int | None = None,
156
+ details: Mapping[str, Any] | None = None,
157
+ request_id: str | None = None,
158
+ retry_after_seconds: int | None = None,
159
+ ) -> None:
160
+ super().__init__(
161
+ message,
162
+ code=code,
163
+ status=status,
164
+ details=details,
165
+ request_id=request_id,
166
+ retry_after_seconds=retry_after_seconds,
167
+ )
168
+ self.reason = reason
169
+
170
+
171
+ def contract_type_of(error: SlenError) -> str:
172
+ return type(error).__name__.removesuffix("Error")
173
+
174
+
175
+ def refused(message: str) -> InvalidRequestError:
176
+ return InvalidRequestError(message, code=INVALID_REQUEST)