Error Handling
Solwyn SDK error categories — pre-flight, call-time, and silent errors — and how to test each path
import os
from openai import OpenAI
from solwyn import (
BudgetExceededError,
ConfigurationError,
ProviderUnavailableError,
RunStoppedError,
Solwyn,
)
try:
client = Solwyn(
OpenAI(),
api_key=os.environ["SOLWYN_API_KEY"],
budget_mode="hard_deny",
)
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
)
print(response.choices[0].message.content)
except ConfigurationError as e:
print(f"Bad config ({e.field}): {e.message}")
except RunStoppedError as e:
print(f"Run {e.agent_run_id} was stopped ({e.source}: {e.reason})")
except BudgetExceededError as e:
print(f"Budget blocked: ${e.current_usage:.2f} / ${e.budget_limit:.2f}")
except ProviderUnavailableError as e:
print(f"No provider could be dispatched. Candidates: {e.attempted}")
finally:
client.close()Solwyn SDK errors fall into three categories: pre-flight errors that prevent construction, errors raised during calls when the SDK cannot or must not proceed, and silent errors that are logged but never raised.
Error categories
1. Pre-flight errors (construction time)
These are raised when Solwyn() or AsyncSolwyn() is constructed with invalid configuration:
| Exception | field | Example |
|---|---|---|
ConfigurationError | "api_key" | Bad API key format |
ConfigurationError | "fallback_specs" | Malformed fallback tuple |
ConfigurationError | "provider" | Unknown name in provider= or a fallback tuple's fourth element |
ConfigurationError | "client" | A provider= pin that does not match the client's family or sync/async mode, or wrapping an already-wrapped client |
ConfigurationError | "tags" | Invalid client default tags or malformed SOLWYN_TAGS |
ConfigurationError | "acknowledge_untracked" | A token that is not an exact untracked leaf on this client — see Coverage controls |
ConfigurationError | "failover_total_timeout", "failover_hop_read_timeout", or any other config field | A value pydantic rejects — for the timeouts, anything non-finite (booleans, NaN, inf) and a hop read timeout of zero |
from openai import OpenAI
from solwyn import ConfigurationError, Solwyn
try:
client = Solwyn(
OpenAI(),
api_key="not_a_valid_key",
)
except ConfigurationError as e:
print(f"Field: {e.field}") # "api_key"
print(f"Error: {e.message}") # format validation messageConfigurationError is raised immediately -- no LLM call is attempted. Constructing a SolwynConfig directly raises pydantic's ValidationError instead; the client wrapper is what converts it.
2. Call-time errors
These are raised during LLM calls when the SDK cannot proceed:
| Exception | When raised | Requires |
|---|---|---|
BudgetExceededError | Budget exhausted before a call — project period, per-run cap, or a scoped rule (budget_period names which) | budget_mode="hard_deny", or a scoped rule in hard-deny mode |
RunStoppedError | The active run was stopped by an operator or by local velocity detection. Not a BudgetExceededError, so a budget handler cannot swallow it. Raised regardless of budget_mode and fail_open. | A solwyn.run(...) scope |
ProviderUnavailableError | No candidate could be dispatched — every circuit open, or the failover window expired. If every candidate was attempted and failed, the last provider's own exception propagates instead | Provider down (circuit breaker triggered), or failover_total_timeout expired |
UntranslatableRequestError | A cross-provider failover hop cannot translate the request shape | fallback= chain with mixed providers |
UntranslatableModelError | A fallback entry has no model configured for its provider | fallback= chain with mixed providers |
UntrackedSpendSurfaceError | Your code reached a capability Solwyn does not meter — before any provider I/O | on_unmetered="raise" |
UnsupportedSurfaceError | The selected adapter has no seam for a wrapper surface (for example videos.create on a compatible endpoint, or embeddings.create on an Anthropic client). Not raised for the Responses API: on a non-Azure compatible endpoint the Responses leaves are untracked and follow on_unmetered | — |
CoverageMismatchError | coverage(client).expect(...) found the classification drifted from your pin | An explicit coverage() call, usually in a test |
The two translation errors are raised before any network call and abort the whole failover chain. They carry structural labels only (source, target, feature / model, provider) — never prompt content. See Provider Failover.
ConfigurationError is also raised at call time in a few deliberate cases, each one preventing a call that would bypass metering:
- calling
invoke_model,invoke_model_with_response_stream, orstart_async_invokeon a Bedrock-wrapped client (fieldis the method name) — useconverse()/converse_stream(), or the unwrapped boto3 client for deliberately untracked calls. See Amazon Bedrock. - a metered Responses call with
background=True(field="background"), a streamingresponses.parse(field="stream"), or anextra_bodythat overridesmodel,input,instructions,max_output_tokens, orstream(field="extra_body"). See The Responses API.
Provider errors from the underlying SDK (e.g. openai.APIError, anthropic.APIError, botocore.exceptions.ClientError) are not caught by Solwyn. When the failover chain cannot recover, they propagate directly to your code, just as they would without the wrapper.
3. Silent errors (logged only)
These are internal errors that the SDK handles gracefully without raising:
| Situation | Behavior |
|---|---|
| Solwyn Cloud API unreachable (budget check) | fail_open=True: proceed with local tracking. fail_open=False: enforce locally. A retained run stop or a sticky hard deny is preserved either way — see below. |
| Solwyn Cloud API unreachable (reporting) | Events wait in a bounded in-process queue between flushes. Since 0.4.0 delivery is at-least-once: a batch that fails transiently is logged (Failed to send metadata batch) and retried with bounded backoff, not dropped on first failure. Spend that genuinely cannot be delivered is counted and logged. Reporting never blocks LLM calls. See Spend delivery. |
| Per-event ingest rejection (202 with rejection dispositions, SDK v0.1.7+) | Accepted events in the batch are durable; rejected events (unknown_model, unknown_service_tier, invalid_tags, tag_cardinality_exceeded, unsupported_modality) are terminal — logged and dropped, never re-queued. One WARNING per distinct (code, model) per batch: reporter.ingest_events_rejected. See Logging. |
| A denial receipt cannot be delivered | Never dropped. Undeliverable budget_denied events fold into a per-run aggregate and are replayed once delivery recovers, so the dashboard's denied-cost figure stays complete. See Denial receipts and aggregate replay. |
| Budget confirmation failure | Logged as warning, does not affect the LLM call. |
Untracked surface reached under on_unmetered="warn" (the default) | One WARNING per surface per process, then the call is forwarded. See Coverage controls. |
| Merged spend tags exceed 10 keys | One SolwynTagsClampedWarning (a warnings warning, not a log line); the lowest-priority tags are dropped and the call proceeds. See Tags. |
A velocity rule flags the active run under velocity_mode="warn" (the default) | velocity.flagged WARNING, rate-limited; the call proceeds. Under deny, the run is stopped locally and the next call raises RunStoppedError. See Run control. |
Silent errors are logged via the standard Python logging module at the WARNING level. Enable logging to see them:
import logging
logging.basicConfig(level=logging.WARNING)Exception hierarchy
All Solwyn exceptions inherit from SolwynError:
SolwynError (base -- catch this to handle any SDK error)
+-- BudgetExceededError
+-- RunStoppedError
+-- ProviderUnavailableError
+-- ConfigurationError
+-- UntranslatableRequestError
+-- UntranslatableModelError
+-- UnsupportedSurfaceError
+-- UntrackedSpendSurfaceError (also an AttributeError)
+-- CoverageMismatchErrorUntrackedSpendSurfaceError inherits from AttributeError as well, so hasattr(client.moderations, "create") and getattr(client.moderations, "create", None) keep working as feature probes under strict mode (probe the leaf — client.moderations itself is a namespace and is never refused). SurfaceInspectionError (raised when coverage() cannot observe part of a client) and the SolwynTagsClampedWarning warning class are exported too but sit outside this tree — the first is a RuntimeError, the second a UserWarning.
SolwynError is exported from the top-level package alongside its subclasses. Use it when you want a single except clause for any Solwyn error:
from solwyn import SolwynError
try:
response = client.chat.completions.create(...)
except SolwynError:
# catches all Solwyn-raised errors
...Order matters when you catch both RunStoppedError and BudgetExceededError: neither inherits from the other, so either order works, but a bare except SolwynError placed first will take both.
fail_open behavior
The fail_open setting controls what happens when Solwyn Cloud is unreachable:
fail_open | Cloud unreachable behavior |
|---|---|
True (default) | LLM calls proceed. Usage tracked locally. Warning logged. |
False | Budget enforced locally using last known limit. If no limit is known, calls are denied. |
Two decisions never fail open, whatever this setting says: a run stop the SDK has already received (server or local) keeps raising RunStoppedError through the outage, and a sticky hard deny on record for the run or project period is preserved rather than forgotten. An outage only affects calls that have no authoritative verdict yet. See Run control and When Solwyn Cloud is unreachable.
import os
from openai import OpenAI
from solwyn import Solwyn
# Default: fail_open=True -- calls always proceed
client = Solwyn(
OpenAI(),
api_key=os.environ["SOLWYN_API_KEY"],
)
# Strict: fail_open=False -- enforce locally when cloud is down
client = Solwyn(
OpenAI(),
api_key=os.environ["SOLWYN_API_KEY"],
fail_open=False,
budget_mode="hard_deny",
)Testing error handling
SDK v0.6.0+: every path above can be exercised without a network. solwyn.testing.FakeControlPlane scripts denials (model="solwyn-test/deny"), outages (plane.outage()), operator kills (plane.stop_run(run_id)), ingest rejections, and lease refusals against the real SDK wire models, and records what the SDK sent. The fail-open recipe asserts on the budget check failed warning through caplog. See Testing budget enforcement.
Exception attribute reference
Every exception's attributes — budget_limit, current_usage, budget_period, attempted, field, the RunStoppedError agent_run_id / reason / source, the UntrackedSpendSurfaceError surface / token / kind, the translation-error source/target/feature/model/provider labels, and the rest — live in one place: Exceptions. That reference is the single source for what each exception carries.