Errors and retries
Every exception the SDK raises for an API, connection or configuration
problem subclasses CLIError, importable from cli_sdk. Invalid query or
resource arguments raise ConfigurationError at construction time, before
anything reaches the network; ConfigurationError is also a ValueError,
so existing except ValueError code keeps working.
Exception map
| Exception | Raised when | Retried by default |
|---|---|---|
AuthenticationError | 401: missing or invalid API key | No |
ValidationError | 422: the request body failed validation. .field names the offending field when the server reports one. | No |
InsufficientCalibrationError | A profile is too small for the requested guarantee, and you opted into strict mode (see below); also a 424 from any non-evaluate endpoint | No |
RateLimitError | 429 after retries are exhausted. .retry_after holds the server’s retry-after in seconds, if numeric. | Yes |
BackendError | 502: the model backend errored, or reached a lower access level than the query required (for example a pinned access_hint it cannot satisfy). 529: the service is overloaded. | Yes |
APIConnectionError | The API could not be reached (connection refused, DNS failure, timeout) after the configured retries | Yes |
ConfigurationError | Client-side misconfiguration, raised before any request (also a ValueError): a query missing a required field or with an invalid one (Set with fewer than 2 options, Gate with target outside , Route with guarantee="cost_budget" and no target_cents, …); an invalid calibration_profiles.create method or alpha; an invalid monitor type, missing target or false_alarm_rate; a CalibrationExample with an unknown source; no API key for a non-local base_url; no backend for a query that does not declare its own cascade (Route, or Judge with cascade); an empty queries map or a value that is not a query; a backend dict without "provider"; a hosted backend without model or with an unknown access_hint; VLLMBackend or SGLangBackend without base_url; an unknown CustomBackend.access_level; a response_model that is not an EvaluateResponse subclass | No |
CLIError | Any other HTTP status of 400 or above, as "HTTP <status>: <message>" | No |
Not wrapped in CLIError:
| Exception | Raised when |
|---|---|
ValueError | An invalid RetryConfig; an unsupported LocalMonitor type; a declared response_model answer that is missing from the response; invalid arrays passed to cli_sdk.stats functions |
TypeError | A declared response_model answer has the wrong type |
NotImplementedError | A CustomBackend evidence method a query needs is not implemented |
from cli_sdk import (
AuthenticationError, BackendError, CLIClient, CLIError, ConfigurationError,
InsufficientCalibrationError, RateLimitError, ValidationError,
)
try:
result = client.evaluate(context=ticket, backend=backend, queries=queries)
except ValidationError as err:
log.error("bad request field=%s: %s", err.field, err)
except RateLimitError as err:
schedule_retry(after=err.retry_after or 30)
except BackendError:
fall_back_to_human_queue()
except InsufficientCalibrationError:
fall_back_to_human_queue() # strict mode: no heuristic answers
except CLIError as err:
log.exception("evaluate failed: %s", err)
fall_back_to_human_queue()Heuristic answers and 424
When a query names a profile that does not yet have enough labelled
examples for its guarantee (below , or
underpowered for the requested bound), the service answers with HTTP 424 Failed Dependency and a body that still contains an answer for every
query, with the affected answers’ cards labelled heuristic.
By default, evaluate returns that response instead of raising: the
answers are usable, and they say plainly that no formal guarantee backs
them.
result = client.evaluate(context=ticket, backend=backend, queries=queries)
if result.heuristic_answers: # e.g. ["route"]
for qid in result.heuristic_answers:
print(qid, result.answers[qid].guarantee.describe())
# Heuristic value: no formal statistical guarantee.
gate = result.answers["route"]
if gate.approved and not gate.is_heuristic:
auto_route()
else:
send_to_human()The SDK applies the same rule to every response, whatever its status: an
answer whose card is missing, or whose type is not one of the eight
known guarantee types, is treated as heuristic. A Gate decision other
than auto_approve, escalate, or abstain is read as escalate. The
SDK never turns an unrecognized answer into an approval.
strict_guarantees
Pass strict_guarantees=True to refuse heuristic answers outright:
client = CLIClient(strict_guarantees=True)evaluate then raises InsufficientCalibrationError whenever any
answer in the response is heuristic — on a 424, and also on a 200
response that contains a heuristic answer. The message lists the query ids.
Use strict mode in paths where acting on an unguaranteed answer is never
acceptable, and handle the exception by falling back to human review.
Strict mode changes what you receive, not what is guaranteed. A non-heuristic answer is still a marginal or group-conditional guarantee over exchangeable data, never a promise about the one request in front of you.
Retries
429, 502 and 529, connection errors and timeouts are retried
automatically; everything else (authentication, validation, insufficient
calibration) is returned immediately, because retrying cannot change the
outcome. Every POST carries an Idempotency-Key header, one value per
logical call reused across its retries, so a retried write (for example
add_examples after a 502 that the server had already committed) is
applied once.
from cli_sdk import CLIClient, RetryConfig
from cli_sdk.client import NO_RETRY
client = CLIClient(retry=RetryConfig(max_attempts=5, base_delay=0.5, max_delay=8.0))
no_retries = CLIClient(retry=NO_RETRY) # RetryConfig(max_attempts=1)RetryConfig field | Default | Meaning |
|---|---|---|
max_attempts | 4 | Total attempts, including the first. Must be at least 1. |
base_delay | 0.5 | Seconds; the backoff ceiling for the first retry. |
max_delay | 8.0 | Seconds; cap on a backoff wait. |
max_retry_after | 60.0 | Seconds; the longest server retry-after the client waits for. A longer one stops retrying and raises (RateLimitError carries .retry_after). |
retry_connection_errors | True | Retry connection errors and timeouts; after the last attempt they raise APIConnectionError. |
retry_statuses | frozenset({429, 502, 529}) | Statuses that trigger a retry. |
Before retry (the wait after failed attempt ), the client sleeps:
- the server’s
retry-aftervalue in full, if it is numeric (and at mostmax_retry_after); otherwise - a uniform random time in (full-jitter exponential backoff).
When the last attempt still fails, the matching exception is raised
(RateLimitError for 429, BackendError for 502 and 529,
APIConnectionError for a connection error or timeout).
A few consequences worth knowing:
- Size
timeoutfor L0 backends, which make one model call per sample. - With a
CustomBackend, evidence is computed once, before the first attempt; retries resend the same evidence and do not call your model again. RetryConfigis a frozen dataclass. Share one instance across clients.
Request ids
Every response carries a request id (result.request_id, from the
x-request-id header). Log it next to any decision you act on: together
with the guarantee card’s calibration_profile and last_audited, it lets
you trace a decision back to the exact calibration that backed it.