( code: ErrorCode, attempt: number, retryAfterSec: number | undefined, random: () => number, retryOnConflict = true, retryOnRateLimit = true, )
| 724 | } |
| 725 | |
| 726 | function apiRetryDecision( |
| 727 | code: ErrorCode, |
| 728 | attempt: number, |
| 729 | retryAfterSec: number | undefined, |
| 730 | random: () => number, |
| 731 | retryOnConflict = true, |
| 732 | retryOnRateLimit = true, |
| 733 | ): RetryDecision { |
| 734 | switch (code) { |
| 735 | // M3.2 piece-1 / piece-2: `PRECONDITION_FAILED` (etag mismatch), |
| 736 | // `IDEMPOTENCY_BODY_MISMATCH` (caller mutated body across retries), |
| 737 | // and `PAYLOAD_TOO_LARGE` (350 KB cap) are all caller-side bugs — |
| 738 | // never retry, exit immediately with the typed envelope. |
| 739 | // INSUFFICIENT_CREDITS is non-retriable: out-of-credits cannot self-heal. |
| 740 | // The RATE_LIMITED credits sub-case is re-mapped to INSUFFICIENT_CREDITS |
| 741 | // in parseEnvelopeBody (errors.ts) before apiRetryDecision is called, |
| 742 | // so the genuine per-minute throttle (RATE_LIMITED) reaches the RATE_LIMITED |
| 743 | // case below and retries normally. |
| 744 | // FEATURE_GATED is non-retriable: a paid-feature gate can't self-heal with |
| 745 | // retries — the caller must upgrade their plan first. |
| 746 | case 'AUTH_REQUIRED': |
| 747 | case 'AUTH_INVALID': |
| 748 | case 'AUTH_FORBIDDEN': |
| 749 | case 'NOT_FOUND': |
| 750 | case 'VALIDATION_ERROR': |
| 751 | case 'PAYLOAD_TOO_LARGE': |
| 752 | case 'PRECONDITION_FAILED': |
| 753 | case 'IDEMPOTENCY_BODY_MISMATCH': |
| 754 | case 'UNSUPPORTED': |
| 755 | case 'INSUFFICIENT_CREDITS': |
| 756 | case 'FEATURE_GATED': |
| 757 | return { retry: false, delayMs: 0 }; |
| 758 | case 'CONFLICT': |
| 759 | // Read paths (e.g. GET /failure) retry once: 409 = mid-mutation snapshot. |
| 760 | // Write paths (e.g. POST /runs) must NOT retry: 409 = another run is in |
| 761 | // flight (persistent), retrying would enqueue a second run. |
| 762 | if (!retryOnConflict) return { retry: false, delayMs: 0 }; |
| 763 | if (attempt >= MAX_ATTEMPTS_CONFLICT) return { retry: false, delayMs: 0 }; |
| 764 | return { retry: true, delayMs: CONFLICT_DELAY_MS }; |
| 765 | case 'INTERNAL': |
| 766 | if (attempt >= MAX_ATTEMPTS_INTERNAL) return { retry: false, delayMs: 0 }; |
| 767 | return { retry: true, delayMs: INTERNAL_DELAY_MS }; |
| 768 | case 'RATE_LIMITED': |
| 769 | if (!retryOnRateLimit) return { retry: false, delayMs: 0 }; |
| 770 | if (attempt >= MAX_ATTEMPTS_RATE_LIMITED) return { retry: false, delayMs: 0 }; |
| 771 | return { |
| 772 | retry: true, |
| 773 | delayMs: Math.min(Math.max(0, (retryAfterSec ?? 1) * 1000), MAX_RATE_LIMITED_DELAY_MS), |
| 774 | }; |
| 775 | case 'UNAVAILABLE': |
| 776 | if (attempt >= MAX_ATTEMPTS_UNAVAILABLE) return { retry: false, delayMs: 0 }; |
| 777 | return { retry: true, delayMs: backoffDelay(attempt, random) }; |
| 778 | } |
| 779 | } |
| 780 | |
| 781 | function backoffDelay(attempt: number, random: () => number): number { |
| 782 | const base = RETRY_BASE_MS * Math.pow(2, attempt - 1); |
no test coverage detected