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.
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.
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. - Held leases are surrendered too, 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.
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 | 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
Async Usage
Use AsyncSolwyn for async applications — context managers, event loops, and provider examples
Coverage controls
Decide what happens when your code reaches a provider capability Solwyn does not meter — warn once, refuse, or allow — and pin the coverage manifest in CI so drift fails a test instead of a budget.