Skip to content

Troubleshooting

Start with the terminal outcome: did the operation fail, how many times did it run, which class was selected, and why did retries stop? Reproduce with a local operation before changing production limits.

Capture a diagnostic outcome

This standalone example intentionally produces an unknown failure:

from redress import Policy, Retry, StopReason, default_classifier
from redress.testing import instant_retries

def broken_operation():
    raise RuntimeError("example failure")

policy = Policy(retry=Retry(
    classifier=default_classifier,
    strategy=instant_retries,  # Diagnostic example only; use jitter in production.
    max_attempts=5,
    max_unknown_attempts=2,
    deadline_s=5.0,
))
outcome = policy.execute(
    broken_operation, operation="diagnostic", capture_timeline=True,
)
assert not outcome.ok
assert outcome.attempts == 3
assert outcome.stop_reason is StopReason.MAX_UNKNOWN_ATTEMPTS
assert outcome.timeline is not None
for event in outcome.timeline.events:
    print(event.attempt, event.event, event.stop_reason)

execute() returns a RetryOutcome for ordinary operation failures. Inspect ok, attempts, last_class, stop_reason, and cause; use last_exception or last_result when needed. Successful outcomes have no stop reason. Cancellation, KeyboardInterrupt, and SystemExit still propagate. Do not log entire results, exception messages, or request payloads by default.

For async operations use AsyncPolicy(retry=AsyncRetry(...)) and await policy.execute(...). Timeline capture is opt-in on execute().

Match the symptom to the cause

Symptom or stop reason What to check Remedy
NON_RETRYABLE_CLASS Classifier returned AUTH, PERMISSION, or PERMANENT Fix credentials/input or correct the classifier; a strategy cannot make those classes retryable
MAX_UNKNOWN_ATTEMPTS Unrecognized exceptions or results Add an explicit classification; the default cap of 2 stops on the third unknown failure
MAX_ATTEMPTS_GLOBAL Total calls reached max_attempts Count the initial call; inspect whether more attempts could actually recover
MAX_ATTEMPTS_PER_CLASS Failures exceeded the configured class allowance Check the class-specific counter; a limit of 0 allows the initial operation but no retry for that class
NO_STRATEGY Neither a matching per-class strategy nor a fallback exists Configure strategy or cover the intended class in strategies
DEADLINE_EXCEEDED Attempts, hooks, or waits used the retry time budget Inspect timing and client timeouts before increasing the deadline
BUDGET_EXHAUSTED Other calls consumed the shared rolling-window allowance Inspect sharing scope and downstream load; avoid recreating the budget per call
ABORTED abort_if, an AbortRetry exception, or an aborting sleeper Check shutdown/drain state and sleeper decisions
SCHEDULED A sleeper returned SleepDecision.DEFER Arrange execution externally using next_sleep_s; Redress has not queued a job
Zero attempts and CircuitOpenError Circuit breaker rejected admission Inspect downstream health and breaker recovery settings

Class allowances count failures of that class within one execution and stop when the count exceeds the allowance. For example, per_class_max_attempts={ ErrorClass.TRANSIENT: 1} permits a retry after the first transient failure and stops at the second, unless another limit stops execution first.

A breaker rejection from execute() is stored in last_exception, with zero attempts and stop_reason=None; do not look for a circuit-open StopReason. call() raises CircuitOpenError. See Circuit breakers.

Results appear successful even when the service failed

By default only exceptions trigger classification. An HTTP client returning a 503 response without raising needs a suitable result_classifier, or an operation that raises on that response. A result classifier must return None for success and ErrorClass or Classification for failure, not True/False. See HTTP recipes.

If a failed result exhausts retries, call() raises RetryExhaustedError. Use execute() to inspect last_result and make a fallback decision. Ordinary terminal operation exceptions from call() are re-raised directly.

Calls exceed the deadline or keep running after timeout

The retry deadline does not interrupt an in-flight operation. Sync attempt timeouts cannot stop an already-running thread; async cancellation may need cleanup time. Configure native client timeouts and inspect blocking work. See Performance tuning for the timing model.

Hooks are silent or slow

on_metric(event, attempt, sleep_s, tags) and on_log(event, fields) have different signatures. Their exceptions are swallowed to isolate observability failures. Test adapters directly and add local error reporting around export code. They still run inline, so slow hooks add latency and can block async work. See Observability.

Retry-After or attempt counts look unexpected

Use a classifier that supplies Classification.retry_after_s together with retry_after_or(...). A plain error class does not carry the header value. Selected delays are limited by remaining retry time. Also inspect nested SDK, client, queue, or decorator retries when downstream request counts exceed the policy's attempt count.

The final allowed failed attempt reports exhaustion without emitting retry, consuming a retry token, or sleeping. Earlier retry decisions may still be aborted or deferred, so a retry event does not guarantee another operation invocation. Use outcome.attempts or attempt hooks to count calls.

Prepare a useful bug report

Include Redress and Python versions, sync/async mode, a minimal policy and operation, expected versus actual behavior, and sanitized outcome/timeline fields. Replace external services with local failures and use testing utilities for repeatable timing. Exclude credentials, request bodies, and identifying tags.