Spend delivery
How usage and settlement leave your process — at-least-once delivery, retry and backoff, shutdown and exit behavior, and what happens to spend that cannot be delivered.
Every intercepted call produces two pieces of spend telemetry: a settlement (what the call actually cost, in tokens) and a metadata event (the MetadataEvent the dashboard is built from). A denied call produces a metadata event only — a denial receipt. All of them leave your process on a background reporter, never on the thread that made the LLM call. (The reporter also carries the periodic breaker-state report and, since 0.6.0, the advisory untracked-surface reports; neither is spend and neither is covered here.)
This page describes how that delivery behaves — including when Solwyn Cloud is unreachable, when your process exits, and when it forks.
Nothing here is on the request path. Your LLM calls travel directly to the provider whatever this subsystem is doing; see How it works.
Settlement is off the hot path
New in 0.4.0. Non-streaming chat completions and the whole media lifecycle — embeddings, images, audio, video — used to settle with a blocking request to Solwyn on your calling thread, after the provider had already answered. The provider's response was withheld from you until Solwyn's round-trip returned.
Settlement is now built without I/O and enqueued together with its metadata event as one ordered item, the same path streaming completions already used. You get the provider's response without waiting on any Solwyn round-trip.
Streaming behavior is unchanged — it already worked this way.
At-least-once delivery
Before 0.4.0, a settlement or metadata batch was dropped the first time a send failed. It is now retried with bounded exponential backoff.
To be precise about the guarantee: delivery is at-least-once within the attempt budget. Retries are bounded (reporter_max_send_attempts, default 5), so a long enough outage still ends in a write-off — but that write-off is counted and logged rather than silent, which is the part that changed. This is not a durable queue, and spend is not persisted across a process that dies without flushing.
| Outcome | Treatment |
|---|---|
| Transport error (connection refused, timeout, reset) | Transient — retried. |
| HTTP 408, 429, or any 5xx | Transient — retried. |
| Any other HTTP status | Terminal — not retried. |
The terminal/transient split matters: it guarantees a poison item can never wedge the queue head. A response Solwyn will reject identically forever is written off immediately rather than retried until the attempt budget runs out.
Retries are safe to send freely because Solwyn deduplicates on an idempotency ledger server-side. The SDK does no client-side deduplication.
Three knobs govern it:
| Field | Default | Effect |
|---|---|---|
reporter_max_send_attempts | 5 | Attempts before an item is written off. |
reporter_retry_backoff_base | 1.0 | First retry delay, in seconds. |
reporter_retry_backoff_cap | 60.0 | Ceiling on the growing backoff, in seconds. |
Ordering
Confirm and settlement queues drain strictly FIFO. A head item that fails transiently is requeued and parks its queue for that cycle, so later spend can never be confirmed ahead of earlier acknowledged spend. A practical consequence: during an outage the queue burns one retry attempt per cycle, not one per queued item.
Fairness between confirms and metadata
Changed in 0.8.0. The reporter used to service confirms for as long as they kept arriving, so under a steady stream of successful settlements the metadata batches behind them waited indefinitely and overflowed their queue. Each flush round now handles at most min(reporter_batch_size, reporter_max_queue_size) standalone confirms and the same number of settlement confirms — 50 each at the defaults — followed by a bounded snapshot of the metadata batches that were ready when the round began; events arriving mid-round wait for the next one. A round that hit a cap with work still queued continues immediately instead of waiting out reporter_flush_interval. Held and retrying heads keep their FIFO parking and retry cadence, and a settlement's own confirm still precedes the transfer of its metadata event. reporter_max_in_flight is unchanged: it guards against overlapping event sends (the flush loop racing a close-time flush) and is not a throughput setting — ordinary delivery is one serial sender. Bursts before the first scheduled flush can still overflow the queues, counted under overflow below. reporter_batch_size must now be at least 1, since a zero batch could never make progress.
During a Solwyn outage
When the shared control-plane breaker refuses admission, a settlement is held for a later cycle rather than dropped. Breaker accounting is unaffected — a refusal is not an attempt, so it moves neither the breaker nor the consecutive-failure counter.
If a settlement is ultimately rejected or exhausts its retries, its paired metadata event is still delivered. Ingest is the durable record of spend, and it must not be lost because the settlement half failed.
Undeliverable spend
Spend that cannot be delivered is always counted and loudly logged. It is never silently discarded.
The first write-off logs immediately. After that, at most one aggregated line per 60 seconds, so a sustained outage reports its losses without flooding your logs:
reporter.spend_events_dropped: new=12 totals={'event.retry_exhausted': 12}totals is a cumulative kind.reason breakdown. The kinds are confirm, settlement_confirm, and event; the reasons are:
| Reason | Meaning |
|---|---|
overflow | The item's queue was full and evicted the oldest entry. The two queues have different bounds: metadata events use reporter_max_queue_size (default 10,000, configurable), while confirms and settlements use a fixed internal bound of 1,000 that no setting governs. So raising reporter_max_queue_size will not reduce confirm.overflow or settlement_confirm.overflow drops. |
retry_exhausted | Every attempt in the budget failed transiently. |
terminal_status | Solwyn returned a status the SDK does not retry. |
ingest_rejected | Solwyn accepted the batch but refused this event — see Per-event ingest rejections. |
shutdown_deadline | Still queued when close() ran out of time. |
exit_breaker_open | A known-down control plane refused it during the exit drain. |
closed_enqueue | Enqueued after the reporter was already closed. |
receipt_fold_overflow | SDK v0.6.0+. The denial-receipt aggregate table itself was full. Not the only way a receipt is lost: once close() takes final ownership of the fold table, later receipts count under their ordinary reason instead. See below. |
Each of these means the dashboard undercounts by that many items. Persistent drops are worth investigating; see Troubleshooting.
Denial receipts are the exception to every reason above except the last: a budget_denied event that meets overflow, retry_exhausted, terminal_status, ingest_rejected, shutdown_deadline, exit_breaker_open, or closed_enqueue is folded, not dropped, and is not counted in spend_events_dropped — until close() seals the fold table, after which a receipt is counted under whichever of those reasons it met.
Denial receipts and aggregate replay
SDK v0.6.0+. A denied call reports a budget_denied metadata event — a denial receipt — carrying who refused it (deny_source), why (deny_reason), under which period, and the estimated input tokens and output bound Solwyn Cloud prices the avoided spend from. Receipts are what the dashboard's Saved by Solwyn figure, a run's denied cost, and the evidence for an operator stop are built from. The receipt is reported before the exception is raised, and it accompanies every refused call — a hard deny, a scoped hard-deny rule on an alert-only project, a run stop, or local fail-closed enforcement. An alert_only project-period denial is not a refusal: the call proceeds and reports an ordinary success event, with no receipt. The field-by-field contract is under Denial receipts.
Because a lost receipt would understate avoided spend and erase the trail of a stop, the reporter never writes one off. When a receipt would otherwise be dropped for any terminal reason, it is folded into an in-memory aggregate keyed by run, deny source, deny reason, period, model, provider, region, service tier, modality, and pricing shape — all structural, nothing content-bearing. After the next successful delivery, or at close(), each aggregate is replayed as one event with deny_source: "aggregate_replay", a fresh call id and timestamp, and receipt_aggregate_count set to the number of receipts it stands for. Solwyn Cloud counts the aggregate as that many calls and prices it from the summed tokens.
Bounds keep folding cheap: 256 aggregate keys per process, and 32 exact keys per run, after which further receipts for that run fold into a coarse aggregate whose receipt_pricing_input_tokens is null and is priced conservatively. An aggregate whose count would exceed 100 million splits across several events. When the aggregate table itself is full a receipt is lost, counted as event.receipt_fold_overflow and logged like any other drop. Ingest rejections of a replayed aggregate fold again rather than dropping. The other way a receipt is lost is at the end: close() takes final ownership of the fold table, so a receipt arriving after that — or a final aggregate replay that fails to deliver — is counted at receipt weight under its ordinary reason (event.shutdown_deadline, event.exit_breaker_open, or event.closed_enqueue) rather than folded again. In a test, FakeControlPlane.aggregate_replays records the replayed events — see Testing.
Shutdown
close() is bounded by a single wall-clock deadline — reporter_shutdown_deadline, default 5.0 seconds — shared across the worker join, the final flush, and the last breaker-report cycle. Against an unresponsive Solwyn, shutdown no longer pays a serial timeout for every queued item.
The deadline is a true wall-clock bound. HTTP timeouts cap individual socket operations, not total response time, so the final flush runs off the closing thread and a slow-drip response cannot hold close() open.
At the deadline, close() takes final ownership of everything still undelivered and counts it as shutdown_deadline before returning. Nothing is requeued into a queue that no longer drains.
The deadline is a client configuration field, not an argument to close():
client = Solwyn(OpenAI(), api_key="sk_proj_...", reporter_shutdown_deadline=30.0)
...
client.close()Raise it if a short-lived process is dropping spend on exit; lower it if an unreachable Solwyn is holding up teardown.
Lease surrender at close() has its own bound, separate from reporter_shutdown_deadline: the whole lease drain — releases still owed plus leases still held — gets 3 seconds (changed in 0.7.0, from 1 second). A surrender that times out is retried once, within whatever is left of that budget; the early releases sent at scope exit get their own 6-second budget and so always have room for the retry. Since 0.7.0 a run's lease is normally released when its scope exits, so close() usually has nothing left to surrender. See When a lease is handed back.
Changed in 0.8.0. Those early releases are dispatched by a bounded pool — at most 4 workers and 64 parked releases per enforcer, sharing one dedicated HTTP client — so a burst of finished runs no longer starts a thread and a TLS handshake per release. close() shortens every parked release's 6-second budget to its own 3-second drain and counts whatever is still unsent as expired rather than waiting on it. On AsyncSolwyn the release pool belongs to the event loop that built it: while that loop is alive, close() must run on it (a RuntimeError names the rule otherwise); once the loop is closed the SDK abandons that pool, and a later loop builds a fresh one over the same TLS result.
Interpreter exit
A process that exits without calling close() used to discard everything still queued. An exit hook now flushes each live reporter on the way out, bounded by the same kind of wall-clock deadline so an unreachable Solwyn can never hold up process exit.
Two details worth knowing:
- Settlements ride the control-plane breaker on exit. A Solwyn already known to be down refuses them instantly rather than waiting (counted
exit_breaker_open). Metadata ingest is never breaker-gated, so events still get their deadline-bounded attempt. - Leases still held are surrendered too — since 0.7.0 normally only those of run scopes still open at exit, because a scope exit hands its own lease back — so Solwyn can re-lend the float immediately instead of waiting out the lease deadline. Unlike queued spend, nothing there is worth waiting for — an unsurrendered lease simply expires server-side.
Calling close() explicitly is still better: it gets a full deadline, a clean flush, and a clean surrender. The exit hook is a safety net, not a substitute.
Fork safety
Threads, locks, and HTTP clients do not survive fork(), so a forked child used to inherit a dead flush thread and never deliver its settlements. The SDK now rebuilds locks, swaps in fresh HTTP clients, and relaunches the flush thread on the child's next enqueue.
Two deliberate choices in that reset:
- The parent's sockets are abandoned, never closed — closing them in the child would disrupt the parent.
- Items duplicated by the fork are kept, not discarded. The server deduplicates, so a duplicate is harmless while a discarded item is lost spend.
- Since 0.7.0 a forked child also forgets any lease release the parent still owed — the parent's leases stay the parent's to hand back, since a child posting them would reclaim float the parent is still drawing on.
This matters for pre-fork server models (Gunicorn, uWSGI, multiprocessing) where a client is constructed before the fork.
Copying and pickling the client
SDK v0.6.0+. The wrapper holds live reporter, budget, and lease state, so it refuses to be duplicated by value:
copy.copy(client)andcopy.deepcopy(client)return the same wrapper object rather than cloning it. A framework that deep-copies its configuration keeps sharing one reporter.pickle.dumps(client)raisesTypeError: Solwyn clients hold live reporter/budget state and cannot be pickled; construct a fresh Solwyn(...) in the target process. Build the client inside the worker — or rely on fork inheritance as above — instead of sending it across a process boundary.
Async reporters start on first use
AsyncSolwyn's reporter previously flushed only after an explicit start() — which async with does for you. Constructed without async with, it silently queued events and settlements until close(), so server-side spend tracking drifted.
The flush loop now starts on the first enqueue when a running event loop is present. With no running loop, the item stays queued and the SDK logs one warning per reporter instance:
reporter.enqueue_without_event_loop: no running event loop;
events stay queued until start() or close() runs inside a loopEnqueueing never raises — it sits on the LLM call path, where failing loud is not an option.
async with AsyncSolwyn(...) remains the recommended pattern. See Async usage.
Configuration
| Field | Default | Env var |
|---|---|---|
reporter_batch_size | 50 (minimum 1, SDK v0.8.0+) | SOLWYN_REPORTER_BATCH_SIZE |
reporter_flush_interval | 5.0 | SOLWYN_REPORTER_FLUSH_INTERVAL |
reporter_max_queue_size | 10000 | SOLWYN_REPORTER_MAX_QUEUE_SIZE |
reporter_max_in_flight | 3 | SOLWYN_REPORTER_MAX_IN_FLIGHT |
reporter_max_send_attempts | 5 | SOLWYN_REPORTER_MAX_SEND_ATTEMPTS |
reporter_retry_backoff_base | 1.0 | SOLWYN_REPORTER_RETRY_BACKOFF_BASE |
reporter_retry_backoff_cap | 60.0 | SOLWYN_REPORTER_RETRY_BACKOFF_CAP |
reporter_shutdown_deadline | 5.0 | SOLWYN_REPORTER_SHUTDOWN_DEADLINE |
Full descriptions and validation rules are in SolwynConfig.
Related
- Budget Enforcement — how a call is admitted before it runs
- Run control — the denial receipt fields and what a stop looks like on the wire
- Privacy — the exact field-by-field contract of what is delivered
- Logging — every delivery message the SDK emits
- Troubleshooting — diagnosing missing costs in the dashboard