Automation · · 5 min read
Tenacity: bound retries for AI-worker API reads
Test a focused Python retry policy: recover from a classified temporary read failure, stop after three attempts, and surface permanent errors immediately.
By Sociologix Editorial

Retry the read with a defined stopping point
An AI worker preparing a project brief may need to read an approved project record before continuing. A temporary read failure can justify another attempt. An access-denied response needs a different outcome. Repeating the entire workflow can also repeat actions that already succeeded.
This AI-assisted editorial guide tests Tenacity against local Python fixtures. It isolates one callable read operation, retries only a demonstration exception type, and stops after three total calls. No provider API, CRM, model, customer data or live workflow is connected. The example records requested waits rather than measuring elapsed time.
1. Install a pinned version in a separate environment
We verified Tenacity 9.2.1 on November 5, 2026. The package requires Python 3.10 or later and uses the Apache-2.0 license. Our local fixture run used Python 3.13.15 on Windows. [1]
Run the PowerShell commands below in a new folder. Save the next Python block as retry_reads.py before the final command. Installation requires internet access; after installation the demonstration runs locally.
A plain retry decorator uses much broader defaults than this workflow needs: the guide describes indefinite retries without a delay. Set the retry condition, attempt limit and wait explicitly. [2]
mkdir bounded-read-demo
cd bounded-read-demo
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install "tenacity==9.2.1"
# Save retry_reads.py below first.
.\.venv\Scripts\python.exe retry_reads.py2. Limit the retry boundary to one read
retry_if_exception_type(TemporaryReadError) selects the failures eligible for another attempt. TemporaryReadError is our own fixture class, not an automatic classification of HTTP errors. stop_after_attempt(3) allows three calls in total. reraise=True preserves the underlying final exception when eligible retries are exhausted. [2]
wait_fixed(0.25) requests a quarter-second pause between eligible attempts. The helper defaults to time.sleep, but exercise deliberately substitutes waits.append so tests can inspect the requested pauses without actually sleeping. Tenacity exposes a configurable sleep function for this purpose. [3]
The broad except block only turns fixture outcomes into printable JSON. It does not broaden the retry predicate. A PermissionError or ValueError still exits the retry controller immediately. The returned project identifier is fictional and the ready payload is a controlled fixture.
import json
import platform
import time
from importlib.metadata import version
from tenacity import (
Retrying, retry_if_exception_type, stop_after_attempt, wait_fixed,
)
class TemporaryReadError(Exception):
pass
def read_with_retry(read, *, sleep=time.sleep):
return Retrying(
retry=retry_if_exception_type(TemporaryReadError),
stop=stop_after_attempt(3),
wait=wait_fixed(0.25),
sleep=sleep,
reraise=True,
)(read)
def exercise(mode):
if mode not in {"ok", "recovers", "exhausts", "denied", "bug"}:
raise ValueError("Choose a known fixture")
calls = 0
waits = []
def synthetic_read():
nonlocal calls
calls += 1
if mode == "exhausts" or (mode == "recovers" and calls < 3):
raise TemporaryReadError("Synthetic read unavailable")
if mode == "denied":
raise PermissionError("Synthetic access denied")
if mode == "bug":
raise ValueError("Synthetic invalid response")
return {"project_id": "demo-101", "state": "ready"}
try:
# Record requested waits instead of sleeping in this local fixture.
data = read_with_retry(synthetic_read, sleep=waits.append)
result = {"status": "ok", "data": data}
except Exception as error:
# Demonstration reporting only; the retry predicate is narrower.
result = {"status": "failed", "error_type": type(error).__name__}
return {"fixture": mode, **result, "calls": calls,
"requested_waits_seconds": waits}
if __name__ == "__main__":
print(json.dumps({
"python": platform.python_version(), "tenacity": version("tenacity"),
"examples": [exercise(mode) for mode in
("ok", "recovers", "exhausts", "denied", "bug")],
}, indent=2))
3. Compare the actual outcomes
The ok fixture returned ready after one call with no requested waits. recovers raised our temporary error twice, then returned the same payload on call three. Its requested_waits_seconds array was [0.25, 0.25].
The exhausts fixture made exactly three calls and reported failed with error_type TemporaryReadError. It requested two pauses and no further attempt. The denied and bug fixtures each made one call, requested no pause, and surfaced PermissionError and ValueError respectively.
Ten local functional tests passed. Beyond those five outcomes, they checked payload preservation, a permanent failure following one temporary failure, identity of the final exception, a None return value, and an unclassified LookupError. None counts as success under this exception-only policy: it does not validate the content returned by the read.
These results establish attempt counts and requested delays for controlled callables. They do not establish provider availability, recovery rates, throughput or real-world latency. The test does not exercise an HTTP adapter.
4. Classify provider failures in an explicit adapter
In a proposed integration, translate only the provider failures you have approved for another read into TemporaryReadError. Base that mapping on the provider’s documented behavior and the operation being performed. Keep authentication, permission and malformed-response problems visible instead of relabeling every exception as temporary.
The fixed 0.25-second delay is a demonstration setting. A production adapter needs service-specific pacing, rate-limit handling and any documented retry instructions. Tenacity also offers randomized exponential waits; a cap on each wait still does not impose a timeout on the read itself. [3]
Set connection/read timeouts in the actual client and define an overall workflow deadline separately. An attempt limit cannot interrupt a hung callable. Inspect retries already enabled by an SDK or transport so nested layers do not multiply requests beyond the intended budget.
Retaining a failed status matters. After the limit, create a visible unresolved handoff or return an actionable error to the caller. This tutorial reports failed in JSON; it does not build a durable queue, alerting system or customer-care escalation.
5. Keep successful actions outside the retry block
Wrap the smallest approved read, then validate its returned data before preparing an AI summary. Keep quote creation, email delivery, uploads and record updates outside this demonstration policy. A lost response does not establish whether a remote action succeeded.
HTTP semantics distinguish idempotent operations from ones that cannot generally be repeated safely. Automatic retries of non-idempotent requests need additional knowledge that makes repetition safe, or evidence the original operation was not applied. A generic retry wrapper does not provide that knowledge. [4]
Before connecting a real provider, test the adapter’s transient mapping, permissions, timeout behavior and SDK retry settings in its authorized test environment. Record a request correlation ID, operation and attempt count without logging private payloads. Keep an explicit failure path so the AI worker can pause with useful context for the team.
Sources & further reading
Make an AI-worker handoff more reliable
Bring Sociologix one integration, its documented failure responses and the action your team needs next. We can scope bounded read retries, validation and an unresolved-work handoff around your existing process.
Talk to Sociologix