cocoindex-io/cocoindex · error · ValueError

retry_transient requires a positive timeout

Error message

retry_transient requires a positive timeout

What it means

retry_transient requires its timeout keyword, when provided, to be strictly positive; a zero or negative datetime.timedelta raises this ValueError at call time. timeout=None (no time limit) is the only non-positive-friendly form. A zero timeout would expire before the first attempt could ever start, so the library rejects it as a configuration bug.

Source

Thrown at python/cocoindex/_internal/deadline.py:170

      ``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()
            if remaining is not None and remaining <= 0:
                raise DeadlineExceededError("CocoIndex timeout deadline exceeded")

View on GitHub (pinned to e84aa99b32)

Solutions

  1. Pass a positive timedelta, e.g. timedelta(seconds=30), or pass None to have no timeout.
  2. Default missing config to None, not 0: timeout=cfg.get('timeout') and convert non-None values to timedelta.
  3. Guard the value before the call: if timeout is not None and timeout <= timedelta(0): skip retrying or raise a domain-specific error.
  4. If the budget may legitimately be exhausted before the loop starts, let DeadlineExceededError surface from inside a coco.timeout() scope rather than passing a zero timeout.

Example fix

// before
timeout_s = cfg.get('timeout', 0)
await retry_transient(fn, retry_on=(IOError,), timeout=timedelta(seconds=timeout_s))  # ValueError when 0

// after
timeout_s = cfg.get('timeout')
timeout = timedelta(seconds=timeout_s) if timeout_s else None
await retry_transient(fn, retry_on=(IOError,), timeout=timeout)
Defensive patterns

Strategy: validation

Validate before calling

from datetime import timedelta
def positive_timeout(td):
    if td is None:
        return None
    if td <= timedelta(0):
        raise ValueError(f"timeout must be positive, got {td}")
    return td

Type guard

def is_positive_timeout(v) -> bool:
    from datetime import timedelta
    return v is None or (isinstance(v, timedelta) and v > timedelta(0))

Try / catch

try:
    result = await retry_transient(fn, retry_on=retry_on, timeout=td)
except ValueError as e:
    if 'positive timeout' in str(e):
        result = await retry_transient(fn, retry_on=retry_on, timeout=None)  # no limit
    else:
        raise

Prevention

When it happens

Trigger: Calling retry_transient(fn, retry_on=..., timeout=timedelta(0)) or timeout=timedelta(seconds=-5); computing the timeout as end_time - now after the deadline already passed, or reading 0 from an unset config field.

Common situations: Config-driven timeouts where a missing value defaults to 0 instead of None (cfg.get('timeout', 0)); computing remaining time budgets that have already elapsed before the retry loop starts; tests passing timedelta(0) expecting an instant-expiry behavior instead of DeadlineExceededError.

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/fd27ebf06dba769a. Report an issue: GitHub.