Retry & Fallback¶
Reliable extraction requires two separate retry layers that work independently:
| Layer | What it handles | Configuration |
|---|---|---|
| Validation retries | LLM returned wrong schema / invalid values | max_primary_retries, max_fallback_retries |
| Network retries | Connection errors, timeouts, rate limits | RetryConfig |
On top of both layers, a fallback model can take over when the primary model exhausts its validation retry budget.
Validation retries¶
Runnable example:
examples/03_with_fallback.py
When Pydantic validation fails, the library builds a structured, field-level
error report and appends it to the conversation as a HumanMessage:
"The output format is not correct. Please correct it based on the following errors:
MISSING REQUIRED FIELDS:
- 'revenue_usd_millions': required field is missing
TYPE ERRORS:
- 'fiscal_year': wrong type. Expected: integer, Received: str = '2025'
REQUIRED CHANGES:
1. Add all missing required fields.
2. Correct the data types of the specified fields."
The LLM receives this message and gets another attempt. This loop continues
up to max_primary_retries times.
result, stats = await extract_data(
llm,
MySchema,
messages,
max_primary_retries=5, # allow 5 correction attempts (default: 3)
)
The same mechanism applies to invalid tool calls (malformed JSON): the library detects them, includes the parse error in the retry message, and tries again.
Fallback model¶
Runnable example:
examples/03_with_fallback.py
When the primary model exhausts all its retries, an optional fallback model takes over. It receives a clean context — the original messages plus one sentence noting that a previous attempt failed — so the accumulated retry noise from the primary model does not confuse it.
from langchain_openai import ChatOpenAI
from saidex import extract_data_from_text
primary = ChatOpenAI(model="gpt-4o-mini", temperature=0) # fast + cheap
fallback = ChatOpenAI(model="gpt-4o", temperature=0) # more capable
result, stats = await extract_data_from_text(
primary,
FinancialReport,
report_text,
fallback_llm_model=fallback,
max_primary_retries=2, # fail fast on primary …
max_fallback_retries=3, # … be more patient on fallback
)
print(f"Fallback used: {stats.fallback_used}")
print(f"Primary retries: {stats.primary_retries}")
print(f"Fallback retries: {stats.fallback_retries}")
print(f"Total retries: {stats.total_retries}")
Why use a fallback model?¶
- Cost efficiency: The primary model is cheaper. The fallback only runs when actually needed.
- Reliability: Smaller models sometimes fail on complex or deeply-nested schemas. A larger model handles them as a safety net.
- Clean context: The fallback starts fresh — without the failed attempts that could confuse it.
What the fallback model receives¶
# Original messages (unchanged):
[
SystemMessage("Extract the financial report data."),
HumanMessage(report_text),
]
# + one added hint:
HumanMessage(
"A previous attempt to produce structured output for 'FinancialReport' "
"failed after 2 retries. Please try again carefully, ensuring your "
"response strictly follows the required JSON schema."
)
Reasoning-effort escalation¶
Reasoning models can think harder at a higher cost. Start cheap and only spend more effort when an attempt is rejected:
result, stats = await extract_data(
llm, # e.g. ChatOpenAI(model="gpt-5-mini")
Invoice,
messages,
reasoning_escalation=["low", "medium", "high"],
)
print(stats.reasoning_effort) # level of the attempt that decided the run
- Attempt n of a model binds
reasoning_effort=levels[min(n-1, len-1)]; the last level repeats. Parse errors use up an attempt too. reasoning_escalation=Trueuses the levels from the model's profile (llm.profile["reasoning_effort_levels"]); without them nothing is escalated.Truestarts at the profile's first (cheapest) level and only climbs as far as the attempt budget allows: with the defaults,gpt-5-minigets minimal, low, medium and never high. Pass an explicit list to start higher. It does nothing on langchain-core versions without model profiles (< 1.x). If the profile data is stale, overridellm.profileto bypass the check.- The accepted type is exported as
saidex.ReasoningEscalation. - The fallback model only escalates with its own
fallback_reasoning_escalation. - With a profile, unknown level names raise
ValueErrorbefore any call; a plain string ("high") or an empty list is rejected too. - In the agent loop, only final-answer attempts move to the next level; helper-tool calls keep the current one.
- The level is bound as
reasoning_effort, so the integration must accept it: ChatOpenAIworks (Chat Completions maps it toreasoning_effort, the Responses API toreasoning.effort).- Recent
ChatAnthropicworks: langchain-anthropic mapsreasoning_efforttooutput_config.effort. On adaptive-thinking models this enables thinking, which Anthropic rejects together with a forcedtool_choice. SAIDEX then relaxes the tool binding automatically at the cost of one extra call, so passtool_config=ToolCallConfig.COMPATIBLE. The agent loop is unaffected. - Integrations that do not accept the kwarg (e.g.
ChatOllama), or aChatOpenAI(reasoning=...)set on the model (conflict), fail on the first attempt withfailure_reason="llm_error"andllm_error_kind="invalid_request". - Validators wrapped with
with_contextsee the level asctx.reasoning_effort.
Higher levels cost more tokens and latency — escalation keeps the common case cheap and pays more only for hard documents.
Network retries (RetryConfig)¶
Runnable example:
examples/04_custom_retry_config.py
Network retries are handled separately from validation retries. They catch transient errors — connection resets, timeouts — and rate-limit responses before the validation loop ever sees the result.
Default configuration¶
When retry_config is not provided, the library uses DEFAULT_RETRY_CONFIG:
RetryConfig(
max_retries=3,
retry_delays=[1.0, 2.0, 4.0], # exponential back-off
retryable_exceptions=( # auto-detected from installed packages:
ModelConnectionError, # langchain-core (if recent enough)
ModelTimeoutError,
ModelAPIError, # HTTP 5xx
openai.APIConnectionError, # openai (if installed)
openai.APITimeoutError,
openai.InternalServerError,
httpx.ConnectError, # httpx / httpx2 (if installed)
httpx.TimeoutException,
httpx.RemoteProtocolError,
),
rate_limit_exceptions=( # if available
ModelRateLimitError,
openai.RateLimitError,
),
rate_limit_retry_interval=10.0, # seconds between rate-limit retries
rate_limit_max_duration_seconds=900.0, # give up after 15 minutes
)
Transient errors are langchain-core's ModelConnectionError, ModelTimeoutError
and ModelAPIError (HTTP 5xx), plus openai.APIConnectionError,
openai.APITimeoutError, openai.InternalServerError and the httpx / httpx2
ConnectError, TimeoutException and RemoteProtocolError. Rate limits are
ModelRateLimitError and openai.RateLimitError.
Server errors are retried by default
Server errors (HTTP 5xx) are retried by default; versions up to 0.7.x did not
retry them. These retries stack with the SDK's own retries (for example
ChatOpenAI(max_retries=2)): a persistent 500 can take about 12 HTTP calls before
the fallback model runs. A custom RetryConfig (without server errors, or with
fewer retries) gives faster failover.
A custom RetryConfig(retryable_exceptions=...) keeps full control over what is
retried. To react to a failed run in your own handlers, use
saidex.classify_model_error(exc) or stats.llm_error_kind (see
Failure Reasons).
Custom configuration¶
from saidex import RetryConfig, extract_data_from_text
config = RetryConfig(
max_retries=2,
retry_delays=[2.0, 5.0], # longer waits
rate_limit_retry_interval=30.0, # wait 30 s between rate-limit retries
rate_limit_max_duration_seconds=300.0, # give up after 5 minutes
)
result, stats = await extract_data_from_text(
llm, MySchema, text, retry_config=config
)
The example file 04_custom_retry_config.py
shows this pattern alongside a create_instance_safe standalone demo and a
note on how to add Langfuse tracing via callbacks.
Disabling network retries (e.g. in tests)¶
NO_RETRY = RetryConfig(
max_retries=0,
retry_delays=[],
retryable_exceptions=(),
rate_limit_exceptions=(),
)
result, stats = await extract_data(
llm, MySchema, messages, retry_config=NO_RETRY
)
Custom exception types¶
import httpx
import openai
config = RetryConfig(
retryable_exceptions=(
httpx.ConnectError,
httpx.TimeoutException,
openai.APIConnectionError,
openai.APITimeoutError,
),
rate_limit_exceptions=(openai.RateLimitError,),
)
RetryConfig reference¶
| Field | Type | Default | Description |
|---|---|---|---|
max_retries |
int |
3 |
Max transient-error retries per LLM call |
retry_delays |
list[float] |
[1.0, 2.0, 4.0] |
Seconds to wait before each retry |
retryable_exceptions |
tuple[type[Exception], ...] |
langchain-core, openai and httpx network and 5xx errors | Exceptions that trigger a transient retry |
rate_limit_exceptions |
tuple[type[Exception], ...] |
ModelRateLimitError, openai.RateLimitError |
Exceptions that trigger rate-limit wait |
rate_limit_retry_interval |
float |
10.0 |
Seconds between rate-limit retries |
rate_limit_max_duration_seconds |
float |
900.0 |
Total seconds before giving up on rate-limit retries |
Full retry flow¶
extract_data(primary_llm, schema, messages)
│
│ Phase 1 — Primary model
│ ┌────────────────────────────────────────────────┐
│ │ for attempt in range(max_primary_retries): │
│ │ try LLM call (with network retries) │
│ │ validate response with Pydantic │
│ │ if OK → return (instance, stats) │
│ │ else → append error message, retry │
│ └────────────────────────────────────────────────┘
│ ↓ all attempts failed
│
│ Phase 2 — Fallback model (if configured)
│ ┌────────────────────────────────────────────────┐
│ │ reset to original messages + hint │
│ │ for attempt in range(max_fallback_retries): │
│ │ try LLM call (with network retries) │
│ │ validate response with Pydantic │
│ │ if OK → return (instance, stats) │
│ │ else → append error message, retry │
│ └────────────────────────────────────────────────┘
│ ↓ all attempts failed
│
└─ return (None, stats)
Error handling¶
Always check whether the result is None before using it:
result, stats = await extract_data_from_text(llm, MySchema, text)
if result is None:
logger.error(
"Extraction failed after %d retries (fallback used: %s)",
stats.total_retries,
stats.fallback_used,
)
# Options:
# - raise an exception
# - return a default / partial result
# - skip this record and continue
raise RuntimeError("Could not extract structured data from the document.")
# result is guaranteed valid here
process(result)
stats.failure_reason tells you why the run failed and whether retrying later
is worthwhile — see Failure reasons.
Tracking retry costs¶
ExtractDataStats lets you track quality metrics over time:
results = []
for text in documents:
result, stats = await extract_data_from_text(llm, MySchema, text)
results.append({
"result": result,
"retries": stats.total_retries,
"fallback": stats.fallback_used,
"failed": result is None,
})
failures = sum(1 for r in results if r["failed"])
avg_retries = sum(r["retries"] for r in results) / len(results)
print(f"Failure rate: {failures}/{len(results)}, avg retries: {avg_retries:.2f}")
Related¶
- Extraction — the two extraction functions in detail
- Schema Design — reducing retries by writing better schemas
- Validators — reducing retries by cleaning LLM output in the schema
- Observability — tracing retries with Langfuse / LangSmith