Skip to content

tenacity (retries)

interlock deliberately ships no retry engine of its own: tenacity already does backoff, jitter, stop conditions and predicates well. The interlock-cb[tenacity] extra adds the glue where retry × breaker composition goes wrong in practice.

uv add 'interlock-cb[tenacity]'
pip install 'interlock-cb[tenacity]'
poetry add 'interlock-cb[tenacity]'

Read Retries and circuit breakers first if you are deciding how to combine the two patterns; this page documents the helpers.

retry_unless_open(*transient) retries the listed transient exceptions but stops as soon as the breaker opens. CircuitOpenError is not transient: the breaker rejects instantly, so backing off and retrying it only burns the attempt budget without ever reaching the dependency.

from tenacity import Retrying, stop_after_attempt, wait_exponential_jitter

from interlock import CircuitBreaker
from interlock.integrations.tenacity import retry_unless_open

breaker = CircuitBreaker(name='payments')


@breaker
def charge(amount: int) -> str:
    return gateway.charge(amount)


retrying = Retrying(
    retry=retry_unless_open(TimeoutError, ConnectionError),
    wait=wait_exponential_jitter(),
    stop=stop_after_attempt(5),
    reraise=True,
)

result = retrying(charge, 100)

Called without arguments, retry_unless_open() retries any ordinary Exception — still never CircuitOpenError.

Patient mode (wait for the probe)

Background jobs often prefer waiting over failing. wait_probe(fallback) is a wait strategy: when the last attempt was rejected with a retry_after estimate, it sleeps exactly until the breaker allows the next probe (plus a small jitter so concurrent waiters do not storm the single probe slot). Any other outcome delegates to the fallback strategy.

from tenacity import (
    AsyncRetrying,
    retry_if_exception_type,
    stop_after_attempt,
    wait_exponential_jitter,
)

from interlock import CircuitOpenError
from interlock.integrations.tenacity import wait_probe

retrying = AsyncRetrying(
    retry=retry_if_exception_type((TimeoutError, CircuitOpenError)),
    wait=wait_probe(wait_exponential_jitter()),
    stop=stop_after_attempt(10),
    reraise=True,
)

report = await retrying(nightly_export)

Note the retry predicate: patient mode deliberately does retry CircuitOpenError — that is what makes wait_probe see the rejection and wait the right amount. Keep a stop condition anyway; a dependency can stay down longer than any job should wait.

wait_probe(..., jitter=0.5) widens the random extra wait (seconds) added on top of retry_after; when the rejection carries no estimate (for example after force_open()), the fallback strategy decides.

RetryStrategy — the pipeline layer

The same policies package into a pipeline strategy:

from interlock import Pipeline
from interlock.integrations.tenacity import RetryStrategy

pipeline = (
    Pipeline.builder()
    .retry(attempts=4)  # this step builds a RetryStrategy
    .circuit_breaker(breaker)
    .timeout(2.0)
    .build()
)

Attempts are always capped and the original exception is re-raised when the budget runs out — no RetryError wrapping. The default predicate is retry_unless_open() (fail fast on an open circuit); pass wait=wait_probe(...) and a predicate that includes CircuitOpenError for the patient mode. name= and listener= make every retry visible through the on_retry hook (observability).

Everything else is plain tenacity

Retrying, AsyncRetrying, the @retry decorator, stop and wait strategies compose as usual — the helpers are ordinary tenacity predicates and wait objects, so you can combine them with |, retry_any, wait_chain and friends.