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

  1. Pass max_attempts >= 1, or pass None (omit it) for unbounded attempts.
  2. Clamp the computed value: max(1, computed_attempts).
  3. To effectively disable retrying, keep max_attempts=1 (single attempt) instead of 0.
  4. 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

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


AI-assisted analysis of cocoindex-io/cocoindex@e84aa99b32 (2026-09-08). Data as JSON: /api/errors/94cfd228317ba176. Report an issue: GitHub.