{"record":{"id":"94cfd228317ba176","repo":"cocoindex-io/cocoindex","slug":"retry-transient-requires-max-attempts-1","errorCode":null,"errorMessage":"retry_transient requires max_attempts >= 1","messagePattern":"retry_transient requires max_attempts >= 1","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"python/cocoindex/_internal/deadline.py","lineNumber":168,"sourceCode":"    - Time limits are deadlines, and there is exactly one time concept:\n      ``timeout`` is sugar for running the loop inside a\n      ``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()","sourceCodeStart":150,"sourceCodeEnd":186,"githubUrl":"https://github.com/cocoindex-io/cocoindex/blob/e84aa99b3292c5270a4b313b2a7137ad9ce8ab3b/python/cocoindex/_internal/deadline.py#L150-L186","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","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."],"exampleFix":"// before\nattempts = int(cfg.get('max_attempts', 0))\nawait retry_transient(fn, retry_on=(IOError,), max_attempts=attempts)  # ValueError\n\n// after\nattempts = max(1, int(cfg.get('max_attempts', 1)))\nawait retry_transient(fn, retry_on=(IOError,), max_attempts=attempts)","handlingStrategy":"validation","validationCode":"attempts = cfg.get('max_attempts')\nif attempts is not None:\n    attempts = int(attempts)\n    if attempts < 1:\n        raise ValueError(f\"max_attempts must be >= 1, got {attempts}\")","typeGuard":"def valid_attempts(n) -> bool:\n    return n is None or (isinstance(n, int) and n >= 1)","tryCatchPattern":"try:\n    result = await retry_transient(fn, retry_on=retry_on, max_attempts=attempts)\nexcept ValueError as e:\n    if 'max_attempts' in str(e):\n        attempts = max(1, int(attempts or 1))\n        result = await retry_transient(fn, retry_on=retry_on, max_attempts=attempts)\n    else:\n        raise","preventionTips":["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"],"tags":["python","retry","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"}