Provider Failover
Cross-provider and same-provider failover — the fallback chain, selection policies, request translation, and tuning
import os
from anthropic import Anthropic
from openai import OpenAI
from solwyn import Solwyn
client = Solwyn(
OpenAI(),
model="gpt-4o",
api_key=os.environ["SOLWYN_API_KEY"],
fallback=[
(Anthropic(), "claude-sonnet-4-5"),
],
)
# If the OpenAI call fails (or its circuit is open), Solwyn translates the
# request and retries it on Anthropic — automatically, in-process.
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
)
print(response.choices[0].message.content)
client.close()Solwyn routes each call through an ordered failover chain: the wrapped client first, then each configured fallback in turn. Failover is automatic and in-process — no second deployment, no proxy. The same chain handles two cases:
- Same-provider model fallback — retry on the same client with a different model (e.g.
gpt-4otogpt-4o-mini). - Cross-provider failover — retry on a different provider (e.g. OpenAI to Anthropic), with the request translated to the target's dialect.
The fallback chain
Pass fallback= a list of provider entries. Each entry is a tuple:
(client, model) # provider client + model name
(client, model, default_params) # ... plus fill-absent default request params
(client, model, default_params, provider) # ... plus an explicit provider identityThe optional 4th element names the entry's provider when auto-detection cannot — e.g. (other_client, "my-model", {}, "ollama") for a local server on a non-default port. It follows the same rules as the constructor's provider= argument: it relabels within the client's API dialect only, and an unknown or mismatched value raises ConfigurationError at construction. See OpenAI-compatible providers.
The chain is [primary, *fallbacks]. providers[0] is always the wrapped client; the rest are attempted in the order you list them.
import os
from anthropic import Anthropic
from google import genai
from openai import OpenAI
from solwyn import Solwyn
client = Solwyn(
OpenAI(), # primary
model="gpt-4o",
api_key=os.environ["SOLWYN_API_KEY"],
fallback=[
(OpenAI(), "gpt-4o-mini"), # same provider, cheaper model
(Anthropic(), "claude-sonnet-4-5", {"max_tokens": 1024}), # cross-provider
(genai.Client(api_key=os.environ["GEMINI_API_KEY"]), "gemini-2.0-flash"),
],
)Note: Each entry carries no credentials. Provider API keys live only on the client objects you construct.
ProviderEntryrejectsapi_key/base_urlwith a hard error — Solwyn never accepts, stores, or logs a provider credential.
The model constructor argument
model= names the model for the primary entry. It is optional: for a single-provider client with no fallback, the per-call model= you pass to .create() is authoritative and the constructor model= can be omitted. When you configure a fallback chain, set it so the primary entry is fully described.
default_params
default_params are fill-absent request parameters — applied only when the caller did not pass that key on the call. Precedence is:
per-call kwargs > per-entry default_params > global default_paramsSet a global default for every entry via the constructor, and per-entry defaults inside the tuple. This is how you supply a target provider's required fields — for example Anthropic requires max_tokens, which OpenAI callers do not send:
client = Solwyn(
OpenAI(),
model="gpt-4o",
api_key=os.environ["SOLWYN_API_KEY"],
default_params={"temperature": 0.7}, # applied to every entry
fallback=[
(Anthropic(), "claude-sonnet-4-5", {"max_tokens": 1024}), # entry-only
],
)Same-provider model fallback
When a fallback entry uses the same provider as the primary, only the model is swapped — same HTTP client, same API key, native passthrough (no translation). A success on such a hop is reported with is_model_fallback=true.
import os
from openai import OpenAI
from solwyn import Solwyn
client = Solwyn(
OpenAI(),
model="gpt-4o",
api_key=os.environ["SOLWYN_API_KEY"],
fallback=[(OpenAI(), "gpt-4o-mini")],
)
# A failing gpt-4o call retries on gpt-4o-mini before the chain is exhausted.Cross-provider failover
When a fallback entry targets a different provider, the hop's behavior depends on whether the two providers speak the same API dialect. A success served by a different provider is reported with is_provider_fallback=true either way.
Same-dialect hops (native passthrough)
When source and target both speak the OpenAI Chat Completions dialect — e.g. Groq → OpenRouter, or OpenAI → an OpenAI-compatible provider — the request passes through natively: tools, JSON mode, and streaming all survive, with no canonical-subset restriction. Instead of translating, Solwyn sanitizes the request for the new endpoint:
max_completion_tokensis rewritten tomax_tokensfor targets that need the legacy key, and inverselymax_tokens→max_completion_tokensfor OpenAI/Azureo1/o3/o4/gpt-5targets (when both keys are present in one source, the modern key wins).- A caller-supplied
stream_optionsis stripped when the hop lands on a provider known to reject it. On your configured primary it always reaches the provider untouched. - Endpoint-scoped
extra_headers/extra_query/extra_bodyare stripped on cross-provider hops — they carry gateway credentials authored for the original endpoint — and the target entry's owndefault_paramsversions re-apply. They are untouched on the primary and on same-provider model swaps.
Cross-dialect hops (canonical translation)
When the dialects differ — e.g. OpenAI → Anthropic, or Bedrock → Anthropic — Solwyn translates the request from the source dialect into a canonical subset, then into the target's dialect, before the hop. The response is reshaped to the caller's dialect: a Bedrock caller served by Anthropic still gets a Converse-shaped dict, and an OpenAI caller served by Bedrock gets OpenAI-shaped objects and chunks.
Translation is a structural operation only — it maps request shape (messages, system prompt, tool definitions, common parameters). It never inspects, logs, or rewrites prompt or response content. See Privacy.
Amazon Bedrock participates in both directions via translation — e.g. Bedrock-hosted Claude failing over to direct Anthropic, or an OpenAI primary failing over to Bedrock. Bedrock-specific hop rules (native Bedrock primary calls pass kwargs straight through to boto3, untouched):
inferenceConfig.maxTokensis required for a cross-provider hop from Bedrock — the SDK will not invent an output bound and fails loud with the structural labelmissing_max_tokens.guardrailConfigfails loud on a hop (bedrock.guardrail_config) — a safety feature a hop would otherwise silently strip — as docachePointblocks,s3Locationimage sources, and other shapes outside the canonical subset. See Amazon Bedrock.- boto3 has no per-call timeout override, so the chain's
failover_total_timeoutcannot shorten an in-flight Bedrock hop. Bound it on the client withbotocore.config.Config(retries={"total_max_attempts": 1}, read_timeout=60)so Solwyn owns retries and failover instead of stacking botocore's legacy retry layer. See Timeouts and retries.
Distinct endpoints need distinct provider identities
Circuit-breaker health, latency signals, and failover labeling key off the provider name. Two chain entries that resolve to the same name — two Azure resources, or two unnamed gateways both detected as openai_compatible — share one health domain, are reported as model fallbacks of each other, and skip cross-provider request sanitization (the stream_options strip, the max_completion_tokens rewrite, the endpoint-scoped param strip). A header authored for the first endpoint then reaches the second untouched and can fail there. Give distinct endpoints distinct identities via provider= or the 4th fallback-tuple element.
When a request cannot be translated
On a cross-dialect hop, some request shapes have no equivalent on the target provider. Rather than silently dropping a field, Solwyn fails loud before any network call and aborts the whole chain (tool-using streams across dialects also fail loud, pre-dispatch):
| Exception | Raised when |
|---|---|
UntranslatableRequestError | A request feature has no target equivalent (e.g. an OpenAI response_format Anthropic cannot express, a dangling tool call, a temperature outside the target's range). |
UntranslatableModelError | A fallback entry has no concrete model configured for its provider. |
Both carry structural labels only — source, target, and a feature token like "response_format" — never the offending value and never prompt content.
import os
from anthropic import Anthropic
from openai import OpenAI
from solwyn import Solwyn, UntranslatableRequestError
client = Solwyn(
OpenAI(),
model="gpt-4o",
api_key=os.environ["SOLWYN_API_KEY"],
fallback=[(Anthropic(), "claude-sonnet-4-5", {"max_tokens": 1024})],
)
try:
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
)
except UntranslatableRequestError as e:
print(f"Cannot translate {e.feature} from {e.source} to {e.target}")
client.close()Aborting the chain on an untranslatable feature is deliberate: a translated request that quietly differs from what you asked for is worse than a clear failure. Keep your fallback chain to providers that can serve the same request shape, or scope the untranslatable feature out of calls that may fail over.
Selection policies
A selection policy decides the order in which healthy candidates are attempted. A policy is pure and side-effect-free; it only reorders the usable set, dropping any provider whose circuit is open and not recovery-eligible. Pass one with selection_policy=:
import os
from anthropic import Anthropic
from openai import OpenAI
from solwyn import Solwyn, LatencyPolicy
client = Solwyn(
OpenAI(),
model="gpt-4o",
api_key=os.environ["SOLWYN_API_KEY"],
fallback=[(Anthropic(), "claude-sonnet-4-5", {"max_tokens": 1024})],
selection_policy=LatencyPolicy(),
)| Policy | Ordering within the healthy set |
|---|---|
HealthBasedPolicy | Default. Keeps your configured chain order, ranking CLOSED before HALF_OPEN before recovery-eligible OPEN. |
LatencyPolicy | Prefers the lower observed p50 latency. |
Both apply the same health filter first, so a policy can never promote an unhealthy provider ahead of a healthy one, nor attempt a provider whose circuit is open.
Note:
LatencyPolicyorders by observed latency, so it needs samples before it can act. Solwyn records the latency of each successful call in a rolling window and reports a provider's p50 only after at least 3 successes. Until a provider reaches that threshold it sorts after providers with a known p50, so the chain behaves likeHealthBasedPolicywhile latencies warm up.
Failover tuning
| Field | Default | Description |
|---|---|---|
failover_total_timeout | 30.0 | Wall-clock deadline (seconds) for the entire chain walk. Once exceeded, no further hop is attempted. |
failover_idempotency | "safe" | How aggressively to fail over after an ambiguous failure. See below. |
same_provider_retries | 0 | Max same-provider retries on a 429 whose Retry-After fits the remaining deadline, before failing over cross-provider. 0 = immediate failover. |
circuit_breaker_recovery_timeout_jitter | 0.2 | Fractional jitter applied to the recovery timeout so instances don't probe in lockstep. |
Note: These failover knobs are constructor-only — they have no
SOLWYN_*environment variable. Only the fields listed in SolwynConfig can be set from the environment.
Plan-scoped tuning entitlement
New in 0.3.0. Custom failover and breaker tuning is an entitlement — it is available on plans that carry the failover-tuning entitlement. Each pre-flight budget check opts into a versioned failover directive, and Solwyn Cloud may answer with one. The directive governs a closed set of seven fields — the four failover knobs above and the three circuit-breaker parameters:
| Field | SDK default |
|---|---|
failover_total_timeout | 30.0 |
failover_idempotency | "safe" |
same_provider_retries | 0 |
circuit_breaker_recovery_timeout_jitter | 0.2 |
circuit_breaker_failure_threshold | 3 |
circuit_breaker_recovery_timeout | 60 |
circuit_breaker_success_threshold | 2 |
No other configuration field is touched. The directive has three states:
| Directive | Effect |
|---|---|
| Absent | No-op — your constructor tuning is retained. An older API, a plan whose path returns nothing, or a network failure all land here. Calls never fail for lack of a directive. |
true | Your constructor tuning is (re)applied. |
false | SDK defaults are applied in place of your tuning. |
The transition is reversible in both directions — it is not a one-way ratchet. A false directive replaces your values with the defaults above; a later true restores your original constructor values.
Changes are applied in place to the breakers already running:
- Provider order never changes. The directive tunes timing and thresholds only — it cannot reorder, add, or remove chain entries.
- Breakers keep their identity and their accumulated health and probe state. A
CLOSEDorHALF_OPENbreaker is unaffected mid-episode; anOPENbreaker re-samples its recovery window from the new timeout and jitter. - The chain deadline preserves the original call start. A directive-driven
failover_total_timeoutchange replaces the total but never restarts the clock, so the remaining window can shrink below the time already elapsed. This holds across the sync and async chat and media paths alike.
When a false directive suppresses tuning you actually asked for, the SDK logs one WARNING — Custom failover tuning is unavailable for this plan; SDK defaults applied — at most once per client instance, and only when your requested tuning genuinely differs from the defaults. Clients running default configuration see nothing. See Logging.
Idempotency and duplicate-call safety
A failure after the request reached the provider (a read timeout, a dropped connection mid-response) is ambiguous: the call may already have run and been billed. Failing over would risk a duplicate provider charge. failover_idempotency controls that trade-off:
| Value | Behavior |
|---|---|
"safe" (default) | Fail over on clearly-not-sent errors. On a post-send-ambiguous error, do not fail over; report the call with possibly_succeeded=true so Solwyn Cloud can reconcile. |
"never" | No cross-provider failover at all — stay on the requested provider. |
"always" | Fail over even on ambiguous errors, accepting possible duplicate calls to maximize success. |
Override per call with the solwyn_idempotent keyword (stripped before the request reaches the provider):
# This specific call is safe to retry anywhere — fail over aggressively.
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
solwyn_idempotent=True, # -> "always" for this call
)solwyn_idempotent=True maps to "always", False maps to "safe"; omit it to use the configured failover_idempotency.
Circuit breaker
The circuit breaker tracks the health of each provider in the chain independently. Enforcement is process-local — no breaker state is shared between SDK instances, and no snapshot ever drives admission. From SDK v0.3.0+ that state is also reported to Solwyn Cloud for dashboard visibility; see Breaker state reporting. When a provider's circuit is open, the router skips it and moves to the next candidate without attempting a call.
CLOSED --[failure_threshold failures]--> OPEN
OPEN --[recovery_timeout elapsed]----> HALF_OPEN
HALF_OPEN --[success_threshold OKs]----> CLOSED
HALF_OPEN --[any failure]--------------> OPEN| State | Meaning | Requests allowed? |
|---|---|---|
| CLOSED | Normal operation. | Yes |
| OPEN | Provider is failing. | No — the router fails over to the next candidate. |
| HALF_OPEN | Testing recovery with probe requests. | Yes (limited) |
State transitions
- CLOSED to OPEN: After
failure_thresholdconsecutive failures (default: 3), the circuit opens. The router stops sending to this provider until the recovery timeout elapses. - OPEN to HALF_OPEN: After
recovery_timeoutseconds (default: 60, with jitter), the circuit allows probe requests. - HALF_OPEN to CLOSED: After
success_thresholdconsecutive successes (default: 2), the circuit closes. - HALF_OPEN to OPEN: Any failure during probing immediately re-opens the circuit.
Tuning the circuit breaker
| Parameter | Default | Description |
|---|---|---|
circuit_breaker_failure_threshold | 3 | Consecutive failures before the circuit opens. Lower reacts faster but may trip on transient errors. |
circuit_breaker_recovery_timeout | 60 | Seconds before probing recovery. Lower recovers faster but may probe too early. |
circuit_breaker_success_threshold | 2 | Successes in half-open state needed to close the circuit. |
These three can also be set via SOLWYN_CIRCUIT_BREAKER_FAILURE_THRESHOLD, SOLWYN_CIRCUIT_BREAKER_RECOVERY_TIMEOUT, and SOLWYN_CIRCUIT_BREAKER_SUCCESS_THRESHOLD.
All three are part of the seven-field closed set a plan-scoped failover directive can govern (SDK v0.3.0+) — on a plan without the failover-tuning entitlement they revert to the defaults above.
Note: A same-provider model fallback that fails on both the primary model and the fallback model records only one failure against that provider's breaker — breaker failures are deduplicated per provider within a single request, so one failing request can never burn more than one count toward
failure_thresholdper provider.
Breaker state reporting
New in 0.3.0. The SDK periodically snapshots each provider's breaker state and reports it to Solwyn Cloud, giving the dashboard fleet-wide provider-health visibility. Each report carries structural labels and quantities only — never content:
| Reported | Value |
|---|---|
| Provider | The provider name the breaker tracks. |
| State | closed, open, or half_open. |
| Failure count | Consecutive failures recorded against the breaker. |
| Success count | Consecutive successes recorded against the breaker. |
| Snapshot time | When the snapshot was taken. |
| SDK instance id | A random UUID generated per client instance. |
Reporting is advisory and one-way. Cloud snapshots never drive admission, and breaker state is never shared between instances — every failover decision stays 100% in-process. Turning reporting off changes what the dashboard sees, never how your chain behaves.
Beyond the dashboard, these snapshots drive circuit_breaker_opened and circuit_breaker_closed webhook events and notifications on paid plans, per project, provider, and SDK instance — so a provider tripping in your fleet can page you rather than wait to be noticed. Disabling reporting, or never establishing a project id, means no events fire.
Behavior notes:
- Never blocks a call. Snapshots ride the background reporter on its own worker, off the request path. Every send failure is dropped with a warning — see Logging.
- Needs a project first. Reports are sent only after a successful budget check has established the project id. Until that happens, there are silently no reports.
- One instance id per client. The id is a fresh UUID for each client instance, so client-per-request patterns fragment fleet snapshots into single-use identities. Construct the client once and reuse it.
Off switch — the only new configuration field in 0.3.0:
import os
from openai import OpenAI
from solwyn import Solwyn
client = Solwyn(
OpenAI(),
model="gpt-4o",
api_key=os.environ["SOLWYN_API_KEY"],
breaker_reporting_enabled=False, # or SOLWYN_BREAKER_REPORTING_ENABLED=false
)ProviderUnavailableError
When every candidate in the chain is unavailable — all circuits open, or the chain is exhausted without a success — the SDK raises ProviderUnavailableError:
import os
from anthropic import Anthropic
from openai import OpenAI
from solwyn import ProviderUnavailableError, Solwyn
client = Solwyn(
OpenAI(),
model="gpt-4o",
api_key=os.environ["SOLWYN_API_KEY"],
fallback=[(Anthropic(), "claude-sonnet-4-5", {"max_tokens": 1024})],
)
try:
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
)
except ProviderUnavailableError as e:
print(f"Chain exhausted. Tried: {e.attempted}")
print(f"Provider: {e.provider}, circuit: {e.circuit_state}")
client.close()Attributes
| Attribute | Type | Description |
|---|---|---|
provider | str | None | The unavailable provider, or None when the error describes an exhausted chain. |
circuit_state | str | None | Current circuit breaker state ("closed", "open", "half_open"), or None. |
attempted | list[str] | None | Ordered list of providers the router tried before giving up. Never contains prompt content. |
What the dashboard sees
Every call reports a metadata event tagged with how the chain resolved, so the dashboard can show how often failover saves a request:
| Field | Meaning |
|---|---|
is_model_fallback | A same-provider model swap served the call. |
is_provider_fallback | A different provider than requested served the call. |
requested_provider / requested_model | What you asked for, when failover changed it. |
failover_reason | circuit_open, primary_error, or model_fallback. |
failover_error_class | The exception class name that triggered failover (never the message). |
attempt_index | 0 = primary, 1 = first fallback, and so on. |
possibly_succeeded | Set on a not-failed-over ambiguous abort, for server-side reconciliation. |
These fields carry structural labels only — never prompts, responses, or error strings. See Privacy.
Related
- SolwynConfig — every failover field and its precedence
- Error Handling —
UntranslatableRequestError,UntranslatableModelError,ProviderUnavailableError - Logging — look for
Circuit breaker openedto confirm the circuit tripped - Troubleshooting — failover that fires more (or less) than expected