Errors and retries

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

ExceptionRaised whenRetried by default
AuthenticationError401: missing or invalid API keyNo
ValidationError422: the request body failed validation. .field names the offending field when the server reports one.No
InsufficientCalibrationErrorA profile is too small for the requested guarantee, and you opted into strict mode (see below); also a 424 from any non-evaluate endpointNo
RateLimitError429 after retries are exhausted. .retry_after holds the server’s retry-after in seconds, if numeric.Yes
BackendError502: 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
APIConnectionErrorThe API could not be reached (connection refused, DNS failure, timeout) after the configured retriesYes
ConfigurationErrorClient-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 (0,1)(0, 1), 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 subclassNo
CLIErrorAny other HTTP status of 400 or above, as "HTTP <status>: <message>"No

Not wrapped in CLIError:

ExceptionRaised when
ValueErrorAn 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
TypeErrorA declared response_model answer has the wrong type
NotImplementedErrorA 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 ⌈(1−α)/α⌉\lceil (1-\alpha)/\alpha \rceil, 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 fieldDefaultMeaning
max_attempts4Total attempts, including the first. Must be at least 1.
base_delay0.5Seconds; the backoff ceiling for the first retry.
max_delay8.0Seconds; cap on a backoff wait.
max_retry_after60.0Seconds; the longest server retry-after the client waits for. A longer one stops retrying and raises (RateLimitError carries .retry_after).
retry_connection_errorsTrueRetry connection errors and timeouts; after the last attempt they raise APIConnectionError.
retry_statusesfrozenset({429, 502, 529})Statuses that trigger a retry.

Before retry kk (the wait after failed attempt kk), the client sleeps:

  • the server’s retry-after value in full, if it is numeric (and at most max_retry_after); otherwise
  • a uniform random time in [0, min⁡(max_delay, base_delay⋅2k−1)]\big[0,\ \min(\text{max\_delay},\ \text{base\_delay} \cdot 2^{k-1})\big] (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 timeout for 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.
  • RetryConfig is 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.