slen 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. slen-0.4.0/.gitignore +11 -0
  2. slen-0.4.0/CHANGELOG.md +13 -0
  3. slen-0.4.0/LICENSE +21 -0
  4. slen-0.4.0/PKG-INFO +341 -0
  5. slen-0.4.0/README.md +310 -0
  6. slen-0.4.0/pyproject.toml +125 -0
  7. slen-0.4.0/src/slen/__init__.py +160 -0
  8. slen-0.4.0/src/slen/_client.py +244 -0
  9. slen-0.4.0/src/slen/_config.py +117 -0
  10. slen-0.4.0/src/slen/_errors.py +176 -0
  11. slen-0.4.0/src/slen/_health.py +43 -0
  12. slen-0.4.0/src/slen/_http/__init__.py +0 -0
  13. slen-0.4.0/src/slen/_http/_answer.py +51 -0
  14. slen-0.4.0/src/slen/_http/_async.py +102 -0
  15. slen-0.4.0/src/slen/_http/_clock.py +19 -0
  16. slen-0.4.0/src/slen/_http/_endpoints.py +29 -0
  17. slen-0.4.0/src/slen/_http/_envelope.py +117 -0
  18. slen-0.4.0/src/slen/_http/_error_map.py +115 -0
  19. slen-0.4.0/src/slen/_http/_fields.py +134 -0
  20. slen-0.4.0/src/slen/_http/_journal.py +47 -0
  21. slen-0.4.0/src/slen/_http/_pool.py +13 -0
  22. slen-0.4.0/src/slen/_http/_request.py +91 -0
  23. slen-0.4.0/src/slen/_http/_retry.py +85 -0
  24. slen-0.4.0/src/slen/_http/_sync.py +117 -0
  25. slen-0.4.0/src/slen/_http/_wire.py +102 -0
  26. slen-0.4.0/src/slen/_idempotency.py +17 -0
  27. slen-0.4.0/src/slen/_logger.py +71 -0
  28. slen-0.4.0/src/slen/_modules/__init__.py +0 -0
  29. slen-0.4.0/src/slen/_modules/_context.py +18 -0
  30. slen-0.4.0/src/slen/_modules/_pages.py +65 -0
  31. slen-0.4.0/src/slen/_modules/customers/__init__.py +0 -0
  32. slen-0.4.0/src/slen/_modules/customers/_async.py +318 -0
  33. slen-0.4.0/src/slen/_modules/customers/_calls.py +193 -0
  34. slen-0.4.0/src/slen/_modules/customers/_read.py +106 -0
  35. slen-0.4.0/src/slen/_modules/customers/_sync.py +318 -0
  36. slen-0.4.0/src/slen/_modules/customers/_types.py +101 -0
  37. slen-0.4.0/src/slen/_modules/executions/__init__.py +0 -0
  38. slen-0.4.0/src/slen/_modules/executions/_async.py +97 -0
  39. slen-0.4.0/src/slen/_modules/executions/_calls.py +17 -0
  40. slen-0.4.0/src/slen/_modules/executions/_read.py +118 -0
  41. slen-0.4.0/src/slen/_modules/executions/_sync.py +93 -0
  42. slen-0.4.0/src/slen/_modules/executions/_types.py +160 -0
  43. slen-0.4.0/src/slen/_modules/executions/_wait.py +127 -0
  44. slen-0.4.0/src/slen/_modules/operations/__init__.py +0 -0
  45. slen-0.4.0/src/slen/_modules/operations/_async.py +88 -0
  46. slen-0.4.0/src/slen/_modules/operations/_calls.py +85 -0
  47. slen-0.4.0/src/slen/_modules/operations/_read.py +33 -0
  48. slen-0.4.0/src/slen/_modules/operations/_sync.py +88 -0
  49. slen-0.4.0/src/slen/_modules/operations/_types.py +46 -0
  50. slen-0.4.0/src/slen/_modules/webhooks/__init__.py +0 -0
  51. slen-0.4.0/src/slen/_modules/webhooks/_events.py +138 -0
  52. slen-0.4.0/src/slen/_modules/webhooks/_signature.py +72 -0
  53. slen-0.4.0/src/slen/_modules/webhooks/_types.py +137 -0
  54. slen-0.4.0/src/slen/_modules/webhooks/_webhooks.py +149 -0
  55. slen-0.4.0/src/slen/_observability/__init__.py +0 -0
  56. slen-0.4.0/src/slen/_observability/_recorders.py +50 -0
  57. slen-0.4.0/src/slen/_observability/_scope.py +86 -0
  58. slen-0.4.0/src/slen/_observability/_span.py +36 -0
  59. slen-0.4.0/src/slen/_page.py +13 -0
  60. slen-0.4.0/src/slen/_version.py +3 -0
  61. slen-0.4.0/src/slen/otel.py +36 -0
  62. slen-0.4.0/src/slen/py.typed +0 -0
  63. slen-0.4.0/src/slen/testing.py +152 -0
slen-0.4.0/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ All notable changes to `slen` are written here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/), and before 1.0 a minor version may break.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.4.0] - 2026-10-31
8
+
9
+ ### Added
10
+ - The first release of the Python SDK — the surface of the PHP and TypeScript SDKs 0.4.0: `Slen` and `AsyncSlen`, runs and their wait, end customers, their credits and statements page after page, webhooks, the journal on the standard library's `logging`, spans and an OpenTelemetry bridge (`slen[otel]`), and `slen.testing` for your own tests.
11
+
12
+ [Unreleased]: https://github.com/Alexey-Shpilka/slen-python/compare/v0.4.0...HEAD
13
+ [0.4.0]: https://github.com/Alexey-Shpilka/slen-python/releases/tag/v0.4.0
slen-0.4.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexey Shpilka
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.
slen-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,341 @@
1
+ Metadata-Version: 2.5
2
+ Name: slen
3
+ Version: 0.4.0
4
+ Summary: Slen Python SDK — run flows and read their outcome, manage end customers and their credits, verify webhooks.
5
+ Project-URL: Homepage, https://slen.ai
6
+ Project-URL: Documentation, https://slen.ai/en/docs/sdk/overview
7
+ Project-URL: Repository, https://github.com/Alexey-Shpilka/slen-python
8
+ Project-URL: Issues, https://github.com/Alexey-Shpilka/slen-python/issues
9
+ Project-URL: Changelog, https://github.com/Alexey-Shpilka/slen-python/blob/master/CHANGELOG.md
10
+ Author: Alexey Shpilka
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: credits,flows,llm,opentelemetry,slen,webhooks
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Framework :: AsyncIO
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.11
27
+ Requires-Dist: httpx<1,>=0.27
28
+ Provides-Extra: otel
29
+ Requires-Dist: opentelemetry-api>=1.27; extra == 'otel'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # Slen Python SDK
33
+
34
+ `slen` runs your flows and reads how they ended, keeps your end customers and their credits, and checks the webhooks Slen sends you.
35
+
36
+ - **Two clients**, `Slen` for threads and `AsyncSlen` for asyncio, with the same calls.
37
+ - **One dependency**, [httpx](https://www.python-httpx.org/). OpenTelemetry is an extra, `slen[otel]`.
38
+ - Answers are frozen dataclasses with the wire's field names (`execution_id`, `error_code`); statuses are `StrEnum`s, times `datetime`s with their offset, money and credits decimal strings — never floats. Typed (`py.typed`), checked with mypy `--strict` and pyright strict.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install slen
44
+ ```
45
+
46
+ ## Compatibility
47
+
48
+ | | Versions | Held by |
49
+ |---|---|---|
50
+ | Python | 3.11 and newer | the suite on 3.11, 3.12, 3.13 and 3.14 |
51
+ | httpx | 0.27 and newer, below 1 | the suite on 0.27.0, and on the newest every week |
52
+ | OpenTelemetry | `opentelemetry-api` 1.27 and newer, optional | only `slen.otel` needs it: `pip install "slen[otel]"` |
53
+ | Types | mypy `--strict`, pyright strict | the help examples checked against the installed package |
54
+
55
+ ## The client
56
+
57
+ ```python
58
+ from slen import Slen
59
+
60
+ slen = Slen()
61
+ ```
62
+
63
+ The key is the project's API key; left out, it is read from `SLEN_API_KEY`, and no other variable is read. The client talks to the hosted API. Building it checks the options and touches no network: a missing key is a `ValueError`, an option of the wrong type a `TypeError`, a timeout or a retry count out of range a `ValueError`.
64
+
65
+ | Option | Default | |
66
+ |---|---|---|
67
+ | `api_key` | `SLEN_API_KEY` | the project's key, `sl_live_…` |
68
+ | `timeout` | `65.0` | how long one attempt may take, in seconds; `math.inf` switches the clock off |
69
+ | `connect_timeout` | `5.0` | how long connecting may take |
70
+ | `retries` | `2` | attempts after the first when there is no answer, a 429 or a 503 |
71
+ | `logger` | the `slen` logger | the journal of the SDK — below |
72
+ | `span_recorder` | none | where spans go — below |
73
+ | `webhook_secret`, `webhook_tolerance_seconds` | none, `300` | for checking webhooks — below |
74
+ | `http_client` | one the SDK builds | an `httpx.Client` of yours (`httpx.AsyncClient` for `AsyncSlen`); the SDK does not close it |
75
+ | `headers` | none | sent with every request; the SDK's own headers win over yours |
76
+
77
+ **How a call is written.** What names the thing is positional — an operation and its input, an execution id, an external user id, an amount of credits — and everything else is a keyword argument. Every call that goes to the API also takes `timeout` (a clock for each attempt of that call in place of the client's), `retries` (how often that call is sent again; `0` sends it once) and `request_id` (your own id of the request — below); a call that moves money takes `idempotency_key`. `executions.wait()` takes `timeout_seconds` and `on_poll_error`, and gives each of its reads what is left of its time.
78
+
79
+ `with Slen() as slen:` closes the connections when the block ends, and so does `slen.close()`. The SDK passes its own timeout on every request, so an `httpx.Client` of yours built without one is not cut at httpx's five seconds.
80
+
81
+ ## Run a flow
82
+
83
+ A flow is run by the code of the operation it is bound to. A flow with a model node reads the conversation from `input["messages"]`.
84
+
85
+ ```python
86
+ run = slen.operations.execute(
87
+ "support.reply",
88
+ {"messages": [{"role": "user", "content": "Where is my order?"}]},
89
+ customer="user_8421",
90
+ idempotency_key="ticket-42:reply",
91
+ )
92
+
93
+ print(run.status) # queued | waiting | running | completed | rejected | failed | uncertain
94
+
95
+ if run.is_completed() and run.output is not None:
96
+ print(run.output["content"])
97
+ ```
98
+
99
+ - `customer` is the end customer the run is for, and pays in their credits (`context["user_id"]`); `credit_type` names their account (`context["credit_type"]`). Anything else the flow reads goes in `context`; a `user_id` there that is not `customer` is refused before the wire.
100
+ - **The idempotency key is yours to choose**, derived from the unit of work (`ticket-42:reply`). The server runs a key once: a second request under it runs nothing and answers with that run as it is now (`replayed` is true). Without a key the SDK makes one up, which covers its own retries and nothing else. For a job of your own queue, `idempotency_key_for_job(job_id, "support.reply")` gives every attempt of the job the same key.
101
+ - **A refusal is an answer or an error, depending on the flow.** A `reject` node answers with `status` `rejected` and its code in `error_code` (`run.is_rejected()`); a refusal of the platform the flow did not route is raised (below). `failed` and `uncertain` are runs that broke — read `error_code`, and `error_message` to show or log.
102
+ - `is_completed()`, `is_rejected()`, `is_pending()` (`queued`, `waiting`, `running`) and `is_finished()` (anything else) read the status, as in the other SDKs. A status prints as the wire spells it, and equals that string.
103
+ - The money of the run's model calls is held and settled inside the flow. `cost.estimated` is known when a model call was authorized; `cost.actual` arrives a minute or two after the run ended, so the answer of a run that has just finished carries `None` there.
104
+
105
+ `execute_async()` takes the same arguments and answers at once with `status` `queued`, for a run that goes on in the background.
106
+
107
+ ## Wait for a run
108
+
109
+ ```python
110
+ from slen import SlenError
111
+
112
+
113
+ def told(error: SlenError, failures_in_a_row: int) -> None:
114
+ print(error.code, failures_in_a_row)
115
+
116
+
117
+ queued = slen.operations.execute_async(
118
+ "report.summarize",
119
+ {"messages": [{"role": "user", "content": "Summarize the third-quarter report."}]},
120
+ idempotency_key="report-2026-q3:summary",
121
+ )
122
+
123
+ finished = slen.executions.wait(queued.execution_id, timeout_seconds=120, on_poll_error=told)
124
+ ```
125
+
126
+ `wait()` reads the run until it is over, at the pace the server asks for (`retry_after_seconds`; otherwise 1 s doubling up to 30 s). When the time is up it answers the last state it read, which may still be pending — that is not an error. A read that gets no answer, a 429 or a 5xx is told to `on_poll_error` and the wait goes on; any other failure is raised at once, and so is what `on_poll_error` raises. `timeout_seconds` defaults to 300; `math.inf` waits without a ceiling and gives up after ten failed reads in a row.
127
+
128
+ `slen.executions.get(execution_id)` reads the run once; `slen.executions.trace(execution_id)` reads the nodes it walked and its events.
129
+
130
+ ## End customers
131
+
132
+ A customer is your own user, named by your own opaque id (`external_user_id` — not an email address). Their credits sit in one account per credit type.
133
+
134
+ ```python
135
+ slen.customers.create("user_8421")
136
+ slen.customers.top_up("user_8421", "100.0000", note="Invoice 7781", idempotency_key="invoice-7781:topup")
137
+
138
+ customer = slen.customers.get("user_8421")
139
+ account = slen.customers.charge("user_8421", "5.0000", note="PDF export", idempotency_key="export-991:charge")
140
+ ```
141
+
142
+ | Call | What it does |
143
+ |---|---|
144
+ | `list(cursor=, limit=, search=, status=, credit_type=, low_balance=)` | customers with their accounts, a page at a time (`data`, `next_cursor`) |
145
+ | `list_all(cursor=, limit=, …)` | every customer `list()` reads, page after page — below |
146
+ | `get(id)`, `create(id)` | one customer with every account; `create()` is idempotent and moves no credits |
147
+ | `update_account(id, credit_type, allow_negative=, low_balance_threshold=)` | the settings of one account; what is left out goes back to `False` and `"0"` |
148
+ | `block(id, reason=)`, `unblock(id)` | a blocked customer's new runs are refused (`CustomerBlockedError`); their balance can still be settled |
149
+ | `delete(id)` | a customer with no history; one with history is refused (409) — block them instead |
150
+ | `top_up(id, credits)`, `charge(id, credits)` | add credits; take them for a service of your own application. Above zero |
151
+ | `adjust(id, credits)` | an operator's correction, with a sign; not zero |
152
+ | `entries(id, cursor=, limit=, credit_type=, entry_type=)` | the statement of the customer's accounts, a page at a time |
153
+ | `entries_all(id, cursor=, limit=, …)` | every entry of the statement, page after page |
154
+ | `prices(operation_code=, credit_type=)` | what an operation costs in credits: `data`, a handful of rows, no cursor |
155
+
156
+ Credits are a decimal string of at most four places (`"5"`, `"5.25"`, `"-3.0000"`); anything else is refused before the wire. A movement takes `credit_type` (default `"default"`), `note` and `idempotency_key`; under the same key the server moves the credits once.
157
+
158
+ **Every page.** `list_all()` and `entries_all()` answer an iterator — an async one on `AsyncSlen`, read with `async for`:
159
+
160
+ ```python
161
+ for entry in slen.customers.entries_all("user_8421", limit=100):
162
+ print(entry.entry_type, entry.credits)
163
+ ```
164
+
165
+ The arguments are checked at the call; nothing is sent until the loop asks for the first row, and each page is one request, sent when the loop reaches it, so a `break` sends nothing more. `cursor` is where the loop starts, `limit` the size of a page (1 to 100, 50 by default), and `timeout` and `retries` hold for each page. When the rows end exactly at the end of a page, the loop reads one empty page to find out — the API's cursor works that way. A cursor that comes back as it was sent is an `InvalidResponseError`, not an endless loop. To pick up later where you stopped, keep `next_cursor` of a page from `list()` or `entries()` and pass it as `cursor`.
166
+
167
+ ## Webhooks
168
+
169
+ ```python
170
+ from slen import ExecutionFinishedEvent, Webhooks
171
+
172
+ webhooks = Webhooks(secret="whsec_…")
173
+
174
+
175
+ def receive(body: bytes, signature: str | None) -> None:
176
+ event = webhooks.construct_event(body, signature)
177
+
178
+ if isinstance(event, ExecutionFinishedEvent):
179
+ print(event.data.execution_id, event.data.status)
180
+ ```
181
+
182
+ `construct_event()` checks the `Slen-Signature` header against the raw body — exactly the bytes that arrived, as `bytes` or `str` (`await request.body()` in FastAPI, `request.body` in Django) — and reads the event. A signature that does not hold is a `WebhookSignatureError` whose `reason` says which check refused it (`missing_header`, `malformed_header`, `outside_tolerance`, `no_matching_signature`): answer 400 and do nothing with the body. A signed body that is not an event is an `InvalidResponseError` (`webhook_payload_invalid`). `verify()` answers `True` or `False` and reads nothing.
183
+
184
+ `execution.finished`, `customer.balance_low`, `approval.requested` and `approval.decided` are typed (`ExecutionFinishedEvent` and the rest; `isinstance()` or `match` tells them apart). Every other event arrives as an `UnknownWebhookEvent` with its `data` as it was sent. A receiver needs the endpoint's signing secret, not an API key: `Webhooks` takes it and needs no client, and `slen.webhooks` uses the `webhook_secret` of the client unless the call names one. A signature older than the tolerance (300 seconds) is refused; `tolerance_seconds` and `now` of a call change that for one check. A secret that is empty, or none at all, is a `ValueError` before anything is checked.
185
+
186
+ ## The journal
187
+
188
+ ```python
189
+ import logging
190
+
191
+ logging.basicConfig(level=logging.DEBUG)
192
+ logging.getLogger("httpx").setLevel(logging.WARNING)
193
+
194
+ slen = Slen(logger=logging.getLogger("billing.slen"))
195
+ ```
196
+
197
+ The journal goes to the standard library's `logging`: the `slen` logger by default, which drops every record until your application configures it, or the `logger=` you give — a `logging.Logger`, a `LoggerAdapter`, or anything with `debug`, `info`, `warning` and `error`. Each event is a record whose message is the name of the event (`LOG_EVENTS`), and whose fields are attributes of the record (`extra`), so a JSON formatter writes them as fields — the PHP and TypeScript SDKs write the same names with the same fields. Before Python 3.13 a `LoggerAdapter` replaces the `extra` of each call with its own, and the SDK's fields are lost; on 3.13 and later pass it `merge_extra=True`, before that use an adapter whose `process()` merges the two, as the `RequestLog` of the FastAPI and Django help examples does:
198
+
199
+ | Event | Level | When |
200
+ |---|---|---|
201
+ | `slen.request`, `slen.response` | debug | every attempt; the answer, with `status`, `duration_ms` and `request_id` |
202
+ | `slen.retry` | warning | an attempt that is repeated: `reason` `no_answer`, `rate_limited` or `unavailable`, `delay_ms` |
203
+ | `slen.refused` | info | a 4xx |
204
+ | `slen.unauthorized` | error | a 401, or a 403 about the key |
205
+ | `slen.failed` | error | a 5xx, an unreadable answer, or no answer after every attempt — with `error_type` |
206
+ | `slen.wait.poll`, `slen.wait.poll_failed` | debug, warning | the reads of a wait |
207
+ | `slen.webhook.rejected` | warning | a webhook refused, with `reason` |
208
+
209
+ A request is written by its route — `POST /v1/customers/{externalUserId}/charge`, never the values in it. The journal never carries the API key, the webhook secret, an idempotency key, a body, the server's error message, or a value of the path or the query. A logger that raises does not fail the call.
210
+
211
+ **httpx writes a journal of its own**: at `INFO`, the logger `httpx` writes every request with its whole URL, an external user id in it. Raise that logger to `WARNING` in your application, as above, unless you want those lines.
212
+
213
+ ```python
214
+ slen_of_this_request = slen.with_logger(request_logger)
215
+ ```
216
+
217
+ `with_logger()` answers a copy of the client that writes to another logger — one that carries the id of the request your server is handling, say. It sends through the same connections and records into the same span recorder; the client it was made from is not changed, and the copy owns no connections, so closing it closes nothing.
218
+
219
+ ## Spans
220
+
221
+ A run leaves one span: `operations.execute`, `operations.execute_async`, or `executions.wait` for a wait. Its ten fields are the call (`operation`), `outcome` (`ok`, `refused`, `error`), the run's `status`, `duration_ms`, `execution_id`, `operation_code`, `estimated`, `actual`, `error_code` and `cache_hit` — ids, money and a verdict, never a prompt or an answer. Credit movements and webhooks leave none.
222
+
223
+ ```python
224
+ from slen import LoggerSpanRecorder
225
+
226
+ slen = Slen(span_recorder=LoggerSpanRecorder(logging.getLogger("billing.spans")))
227
+ ```
228
+
229
+ `span_recorder` takes anything with `record(span)`: `LoggerSpanRecorder` writes one `slen.span` line per span with its fields, `slen.testing.MemorySpanRecorder` keeps them, and `slen.otel.OtelSpanRecorder` hands them to an OpenTelemetry tracer of yours (`pip install "slen[otel]"`; the export is your `opentelemetry-sdk`'s). A recorder that raises does not fail the call.
230
+
231
+ ## Long-running servers
232
+
233
+ A server builds a client once per process and serves every request with it. A client keeps no state of a call, so one per process is the rule.
234
+
235
+ - **Build the client in the process that uses it**, never when a module is imported: a client made before a server forks its workers (`gunicorn --preload`) would share its connections between them. FastAPI: in the `lifespan` of the application, and `AsyncSlen` in the worker's own event loop. Django: on the first request a process serves. A client cannot be pickled or copied for the same reason: build one in each process.
236
+ - **The context of a request goes through `with_logger()`**: a copy over the same connections and span recorder. Never change the shared client.
237
+ - **Do not wait in a request handler.** `executions.wait()` keeps the handler open for as long as the run takes. Start the run with `execute_async()` and learn its end from the `execution.finished` webhook, or wait in a queue worker.
238
+ - **A run inside a request gets a budget of its own**: `timeout` and `retries` of the call, not the client's minutes.
239
+ - **`FakeTransport` and `MemorySpanRecorder` keep everything** they are given: they are for tests, never for a server.
240
+
241
+ ## The async client
242
+
243
+ ```python
244
+ import asyncio
245
+
246
+ from slen import AsyncSlen
247
+
248
+
249
+ async def main() -> None:
250
+ async with AsyncSlen() as slen:
251
+ run = await slen.operations.execute("support.reply", {"messages": []}, idempotency_key="ticket-42:reply")
252
+ print(run.status)
253
+
254
+
255
+ asyncio.run(main())
256
+ ```
257
+
258
+ `AsyncSlen` takes the same options and has the same calls, awaited; `list_all()` and `entries_all()` are not awaited, they answer an async iterator. Cancelling the task of a call ends it: the cancellation is never repeated and never swallowed. It runs on asyncio; trio is not supported.
259
+
260
+ **The clock of an attempt.** `AsyncSlen` holds each attempt — connecting, sending, the headers and the whole body — to one deadline. `Slen` runs on httpx's clocks, which count each phase, and checks the attempt's deadline between the pieces of the body: an attempt whose body stalls after its headers is a `TimeoutError` after at most about twice `timeout`.
261
+
262
+ ## Request ids
263
+
264
+ Every answer of the API carries the id of its request, the one its log lines and support find it by. Every call that reads an answer gives it to you as `request_id`, read from the `X-Request-Id` header; `None` when the answer had none:
265
+
266
+ ```python
267
+ customer = slen.customers.get("user_8421")
268
+
269
+ print(customer.request_id)
270
+ ```
271
+
272
+ It is the last field of the answer and takes no part in `==`, but `dataclasses.asdict()` carries it; the rows of a page and what an answer holds (the accounts of a customer) have none. `executions.wait()` gives the id of its last read; `delete()` answers nothing. An error carries it as `request_id`. A dataclass that holds a `dict` (`output`, `metadata`) cannot be hashed.
273
+
274
+ **Your own id.** `request_id=` of a call sends your id as `X-Request-Id`, the same on every attempt of the call; the API keeps it, so its log lines, the `request_id` of the journal and of an error carry your id — a call that got no answer at all (`ConnectionError`, `TimeoutError`) raises with your id too, while an answer that brings another id has the last word. It is 8 to 128 of `A-Z a-z 0-9 . _ : -` (`"order-42:charge"`); anything else is an `InvalidRequestError` before the wire, since the API would replace it. One id names one request, so `list_all()`, `entries_all()` and `executions.wait()` take none: Python refuses the argument itself.
275
+
276
+ ## Errors
277
+
278
+ Every failure is a `SlenError` with `code`, `status`, `details`, `request_id` (what support finds the request by — the `X-Request-Id` of the answer, else `error.request_id` of its body; with no answer, the call's own `request_id`) and `retry_after_seconds`; `str(error)` is its message. The code decides the type; a code this version has no name for falls back by status:
279
+
280
+ | Error | Codes | Fallback |
281
+ |---|---|---|
282
+ | `AuthenticationError` | `invalid_api_key`, `insufficient_scope`, `not_authenticated`, `not_a_member`, `account_suspended` | any other 401 / 403 |
283
+ | `PolicyDeniedError` | `policy_denied`, `model_not_allowed` | |
284
+ | `CustomerBlockedError` (a `PolicyDeniedError`) | `customer_blocked` | |
285
+ | `SpendRefusedError` (the family below) | `budget_exceeded`, `operation_limit_exceeded`, `concurrency_limit_exceeded`, `insufficient_credits`, `overrun_stopped`, `overrun_approval_required` | any other 402 |
286
+ | `RateLimitError` | `rate_limit_exceeded` | any other 429 |
287
+ | `IdempotencyConflictError` | `idempotency_conflict`, `idempotency_in_progress` | |
288
+ | `CustomerNotFoundError` | `customer_not_found` | |
289
+ | `ProviderError` | `provider_error`, `provider_unavailable`, `provider_timeout`, `provider_rate_limit`, `provider_not_configured`, `pool_exhausted` | any other 5xx |
290
+ | `OperationUncertainError` | `operation_uncertain` | |
291
+ | `ServiceUnavailableError` | `ledger_unavailable`, `service_unavailable` | |
292
+ | `FlowRuntimeError` | `flow_runtime_error`, `unrouted_port`, `step_limit_exceeded`, `http_request_failed`, `unknown_node`, `node_type_not_supported`, `execution_conflict` | |
293
+ | `TimeoutError` | `execution_timeout`, `background_hold_expired` | no answer in time (the SDK's own, no code) |
294
+ | `ConnectionError` | | Slen could not be reached, or the connection broke (the SDK's own, no code) |
295
+ | `InvalidResponseError` | `invalid_response`, `webhook_payload_invalid` (the SDK's own) | |
296
+ | `WebhookSignatureError` | `webhook_signature_invalid` (the SDK's own) | |
297
+ | `InvalidRequestError` | `cost_calculation_failed`; `invalid_request` (the SDK's own: refused before the wire) | everything else |
298
+
299
+ The same code is the same type in the PHP and TypeScript SDKs. Every 402 extends `SpendRefusedError`, so "money said no" is one `except` that still knows which. A 429 is not in it: `RateLimitError` is the key's own request rate. `slen.TimeoutError` and `slen.ConnectionError` also extend Python's own `TimeoutError` and `ConnectionError`, so an `except TimeoutError:` of either name catches the SDK's.
300
+
301
+ **What is retried.** No answer, a 429 and a 503 are sent again, under the same idempotency key, up to `retries` times: a `Retry-After` of up to 30 s is waited out, a longer one comes back to you as `retry_after_seconds`; otherwise the pause is 200 ms doubling, at most 5 s. A 500, 502 or 504 is never repeated — a provider may have been paid.
302
+
303
+ **Which of them may have cost money.** The money of a run is the money of the model calls its flow made. The types are drawn along that line, with one exception — the second `ProviderError` row, where the code has to be read as well:
304
+
305
+ | Error | Was money spent? | Send it again? |
306
+ |---|---|---|
307
+ | `SpendRefusedError`, `PolicyDeniedError`, `RateLimitError`, `InvalidRequestError`, `AuthenticationError`, `IdempotencyConflictError` — raised before the run began | no — nothing ran and nothing was held | only once what was refused has changed |
308
+ | `ServiceUnavailableError` | no — Slen did not carry the request out | yes, later, under the same key |
309
+ | `TimeoutError`, `ConnectionError` raised by the SDK (`code is None`) | unknown — the request may have reached the server | under the same key only: the SDK has already repeated it so, and the server runs a key once |
310
+ | `OperationUncertainError` | **unknown — it may have been.** A provider was called and its answer never came; the hold is left in place until it expires | not under a new key: under the same one the server answers with the run as it ended and runs nothing |
311
+ | `ProviderError` with a provider's code — `provider_error`, `provider_unavailable`, `provider_timeout`, `provider_rate_limit`, `provider_not_configured`, `pool_exhausted` | no — for the model call that failed: its hold was given back. A model call the run made before it was paid for (last row) | as the last row says; `provider_not_configured` needs a connection set up first |
312
+ | `ProviderError` with any other code, or with none (`code is None`) | **unknown.** A 5xx without a code this version knows lands here too — the page of a proxy that gave up, a bare 500 — and it does not say how far the run got | as for `OperationUncertainError`: not under a new key |
313
+ | `FlowRuntimeError` — and **any of the above out of `operations.execute()`** once the walk has begun: a `ProviderError`, a `SpendRefusedError` or a `PolicyDeniedError` of a later node, `ledger_unavailable`, the server's `execution_timeout` and `background_hold_expired` | whatever model calls the flow made before it stopped were paid for | a repeat under a new key pays for those again. Under the same key the server answers with the run as it ended (`replayed`) and runs nothing — fix the flow or its input, then send it under a new one |
314
+
315
+ A credit movement (`top_up`, `charge`, `adjust`) moves credits once per key: after a `TimeoutError` or a `ConnectionError`, send it again under the same key.
316
+
317
+ A success the SDK cannot read — a 2xx whose body is not the API's envelope, such as the page of a proxy, or an answer that lacks a field — is an `InvalidResponseError` with the status it came with. The call may have gone through; it is not repeated, and the body is never quoted.
318
+
319
+ ## Testing your own code
320
+
321
+ ```python
322
+ import httpx
323
+
324
+ from slen import Slen
325
+ from slen.testing import FakeTransport, MemorySpanRecorder
326
+
327
+ http = FakeTransport().push(200, {"data": {"status": "ok"}})
328
+ spans = MemorySpanRecorder()
329
+ slen = Slen(api_key="key-for-tests", http_client=httpx.Client(transport=http), span_recorder=spans)
330
+ ```
331
+
332
+ `FakeTransport` is an httpx transport for both clients (`httpx.AsyncClient(transport=http)` for `AsyncSlen`): it answers from the queue `push()` and `push_exception()` fill and keeps every request it was given (`requests`, `last()`) — method, URL, headers and the body as JSON. A request with nothing queued is a `LookupError` that names its route. `MemorySpanRecorder` keeps the spans (`spans`, `last()`). Both are what the SDK's own suite uses.
333
+
334
+ ## Development
335
+
336
+ ```bash
337
+ docker run --rm -v "$PWD":/pkg -v slen-venv:/venv -w /pkg -e UV_PROJECT_ENVIRONMENT=/venv python:3.11-slim \
338
+ sh -c 'pip install --quiet uv==0.13.6 && uv sync --locked --all-extras && uv run --no-sync pytest'
339
+ ```
340
+
341
+ The suite reads `contract/` — the contract the SDKs share, copied here and not edited by hand — and runs the examples of `examples/help`. With `SLEN_OPENAPI_SPEC` naming the API's OpenAPI spec, the contract test and the help examples are held against it as well; without it those checks are skipped. `AGENTS.md` has the rules of the package.