cocoindex-io/cocoindex · error · ValueError
retry_transient requires max_attempts >= 1
Error message
retry_transient requires max_attempts >= 1
What it means
retry_transient validates its policy arguments up front and raises this ValueError when max_attempts is given but is less than 1 (0 or negative). max_attempts=None means unlimited retries, so any explicit value must denote at least one attempt. This keeps retry policies meaningful — a policy of 0 attempts is a caller bug, not a valid mode.
Source
Thrown at python/cocoindex/_internal/deadline.py:168
- Time limits are deadlines, and there is exactly one time concept:
``timeout`` is sugar for running the loop inside a
``coco.timeout(...)`` scope, merging with any ambient deadline by
min-nesting. Expiry raises ``DeadlineExceededError``.
Deadline enforcement is best-effort-or-better: no attempt starts past
the deadline, no result is accepted past it (checked after each attempt
completes), backoff sleeps never exceed the remaining time — and with
``bound_attempt=True``, an in-flight attempt is additionally cancelled
at the effective deadline via ``asyncio.wait_for`` and surfaces as
``DeadlineExceededError``.
With neither ``max_attempts`` nor a deadline, retries are unbounded;
this helper is internal and every call site sets at least one limit.
Cancellation, ``KeyboardInterrupt``, and ``SystemExit`` always propagate
untouched.
"""
if max_attempts is not None and max_attempts < 1:
raise ValueError("retry_transient requires max_attempts >= 1")
if timeout is not None and timeout <= timedelta(0):
raise ValueError("retry_transient requires a positive timeout")
if backoff is None:
backoff = exponential_backoff()
scope = _timeout_scope(timeout) if timeout is not None else contextlib.nullcontext()
with scope:
# Exception, not BaseException: doubles as a type-level guard — if
# the except clause below ever widens back to BaseException, this
# assignment becomes a mypy error.
last_error: Exception | None = None
attempt_index = 0
while True:
# Never start an attempt past the deadline. A remaining time of
# exactly zero counts as expired, so a sleep clipped to the
# deadline cannot spin at the boundary.
check_cancellation()
remaining = remaining_seconds()View on GitHub (pinned to e84aa99b32)
Solutions
- Pass max_attempts >= 1, or pass None (omit it) for unbounded attempts.
- Clamp the computed value: max(1, computed_attempts).
- To effectively disable retrying, keep max_attempts=1 (single attempt) instead of 0.
- Validate user-supplied retry config before constructing the call, rejecting 0/negative values with a clear message.
Example fix
// before
attempts = int(cfg.get('max_attempts', 0))
await retry_transient(fn, retry_on=(IOError,), max_attempts=attempts) # ValueError
// after
attempts = max(1, int(cfg.get('max_attempts', 1)))
await retry_transient(fn, retry_on=(IOError,), max_attempts=attempts) Defensive patterns
Strategy: validation
Validate before calling
attempts = cfg.get('max_attempts')
if attempts is not None:
attempts = int(attempts)
if attempts < 1:
raise ValueError(f"max_attempts must be >= 1, got {attempts}") Type guard
def valid_attempts(n) -> bool:
return n is None or (isinstance(n, int) and n >= 1) Try / catch
try:
result = await retry_transient(fn, retry_on=retry_on, max_attempts=attempts)
except ValueError as e:
if 'max_attempts' in str(e):
attempts = max(1, int(attempts or 1))
result = await retry_transient(fn, retry_on=retry_on, max_attempts=attempts)
else:
raise Prevention
- Treat max_attempts=1 as 'no retries'; never encode 'disable' as 0
- Clamp computed/derived attempt counts with max(1, value)
- Validate retry config once at startup, before any retry loop runs
When it happens
Trigger: Calling retry_transient(fn, retry_on=..., max_attempts=0) or max_attempts=-1, typically because a caller computed the count from an empty/default config (e.g. attempts = int(config.get('max_attempts', 0)) or max_attempts=len(items) with no items).
Common situations: Config-driven retry policies where the user set max_attempts: 0 in YAML/env to try to 'disable retries' (the correct way is to omit it and set retry_on to () or handle errors yourself); arithmetic like remaining = max_limit - used_attempts going negative; wiring UI/env values straight into the keyword.
Understand the failure class
Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.
Related errors
- retry_transient requires a positive timeout
- expected None{loc}, got {type(value).__name__}
- expected {tp}{loc}, got {type(value).__name__}: {value!r}
- expected tuple{loc}, got {type(value).__name__}
- expected {tp}{loc}, got {type(value).__name__}
AI-assisted analysis of cocoindex-io/cocoindex@e84aa99b32 (2026-09-08).
Data as JSON: /api/errors/94cfd228317ba176.
Report an issue: GitHub.