{"record":{"id":"fd27ebf06dba769a","repo":"cocoindex-io/cocoindex","slug":"retry-transient-requires-a-positive-timeout","errorCode":null,"errorMessage":"retry_transient requires a positive timeout","messagePattern":"retry_transient requires a positive timeout","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"python/cocoindex/_internal/deadline.py","lineNumber":170,"sourceCode":"      ``coco.timeout(...)`` scope, merging with any ambient deadline by\n      min-nesting. Expiry raises ``DeadlineExceededError``.\n\n    Deadline enforcement is best-effort-or-better: no attempt starts past\n    the deadline, no result is accepted past it (checked after each attempt\n    completes), backoff sleeps never exceed the remaining time — and with\n    ``bound_attempt=True``, an in-flight attempt is additionally cancelled\n    at the effective deadline via ``asyncio.wait_for`` and surfaces as\n    ``DeadlineExceededError``.\n\n    With neither ``max_attempts`` nor a deadline, retries are unbounded;\n    this helper is internal and every call site sets at least one limit.\n    Cancellation, ``KeyboardInterrupt``, and ``SystemExit`` always propagate\n    untouched.\n    \"\"\"\n    if max_attempts is not None and max_attempts < 1:\n        raise ValueError(\"retry_transient requires max_attempts >= 1\")\n    if timeout is not None and timeout <= timedelta(0):\n        raise ValueError(\"retry_transient requires a positive timeout\")\n    if backoff is None:\n        backoff = exponential_backoff()\n\n    scope = _timeout_scope(timeout) if timeout is not None else contextlib.nullcontext()\n    with scope:\n        # Exception, not BaseException: doubles as a type-level guard — if\n        # the except clause below ever widens back to BaseException, this\n        # assignment becomes a mypy error.\n        last_error: Exception | None = None\n        attempt_index = 0\n        while True:\n            # Never start an attempt past the deadline. A remaining time of\n            # exactly zero counts as expired, so a sleep clipped to the\n            # deadline cannot spin at the boundary.\n            check_cancellation()\n            remaining = remaining_seconds()\n            if remaining is not None and remaining <= 0:\n                raise DeadlineExceededError(\"CocoIndex timeout deadline exceeded\")","sourceCodeStart":152,"sourceCodeEnd":188,"githubUrl":"https://github.com/cocoindex-io/cocoindex/blob/e84aa99b3292c5270a4b313b2a7137ad9ce8ab3b/python/cocoindex/_internal/deadline.py#L152-L188","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Pass a positive timedelta, e.g. timedelta(seconds=30), or pass None to have no timeout.","Default missing config to None, not 0: timeout=cfg.get('timeout') and convert non-None values to timedelta.","Guard the value before the call: if timeout is not None and timeout <= timedelta(0): skip retrying or raise a domain-specific error.","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."],"exampleFix":"// before\ntimeout_s = cfg.get('timeout', 0)\nawait retry_transient(fn, retry_on=(IOError,), timeout=timedelta(seconds=timeout_s))  # ValueError when 0\n\n// after\ntimeout_s = cfg.get('timeout')\ntimeout = timedelta(seconds=timeout_s) if timeout_s else None\nawait retry_transient(fn, retry_on=(IOError,), timeout=timeout)","handlingStrategy":"validation","validationCode":"from datetime import timedelta\ndef positive_timeout(td):\n    if td is None:\n        return None\n    if td <= timedelta(0):\n        raise ValueError(f\"timeout must be positive, got {td}\")\n    return td","typeGuard":"def is_positive_timeout(v) -> bool:\n    from datetime import timedelta\n    return v is None or (isinstance(v, timedelta) and v > timedelta(0))","tryCatchPattern":"try:\n    result = await retry_transient(fn, retry_on=retry_on, timeout=td)\nexcept ValueError as e:\n    if 'positive timeout' in str(e):\n        result = await retry_transient(fn, retry_on=retry_on, timeout=None)  # no limit\n    else:\n        raise","preventionTips":["Default timeout config to None (no limit), never 0","Guard for elapsed budgets: if timeout <= timedelta(0), don't start the retry loop","Convert all user/config durations through one helper that rejects non-positive values"],"tags":["python","retry","timeout","validation"],"backgroundTag":"invalid-argument-value","analyzedSha":"e84aa99b3292c5270a4b313b2a7137ad9ce8ab3b","analyzedAt":"2026-09-08T15:59:19.997Z","contentChangedAt":"2026-09-08T15:59:19.997Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}