Thanks to visit codestin.com
Credit goes to github.com

Skip to content

refactor(fs-retry): consolidate file-retry loops behind withRetry - #526

Merged
ndycode merged 2 commits into
mainfrom
claude/audit-07-retry-consolidation
Jun 10, 2026
Merged

ndycode merged 2 commits into
mainfrom
claude/audit-07-retry-consolidation

Conversation

@ndycode

@ndycode ndycode commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Summary

Consolidates the divergent hand-rolled file-retry loops behind a single withRetry helper in lib/fs-retry.ts (the existing home of FILE_RETRY_CODES), per audit roadmap §4.2 (docs/audits/AUDIT_2026-06-10.md, PR #522). Mechanical migration only — every call site keeps its exact attempt count, backoff schedule, jitter, retryable code set, and error propagation. Tuning, if any, belongs in a follow-up PR.

Changes

New API in lib/fs-retry.ts:

withRetry<T>(operation, { maxAttempts, backoffMs, jitterMs?, retryableCodes?, onRetry? }): Promise<T>
withRetrySync<T>(operation, options): T   // for the synchronous recovery paths

backoffMs accepts a number or a per-attempt function (covers the existing linear/exponential schedules); retryableCodes defaults to FILE_RETRY_CODES; non-retryable errors rethrow immediately; exhaustion rethrows the final error unchanged. withFileOperationRetry is now a thin delegate.

14 loops migrated across lib/config.ts (×5), lib/storage.ts (×2), lib/quota-cache.ts (×2, including removing a local duplicate of FILE_RETRY_CODES), lib/recovery/storage.ts (×2, sync), lib/codex-manager/commands/uninstall.ts (local withFileOperationRetry duplicate deleted in favor of the shared one), and lib/fs-retry.ts itself.

3 loops deliberately skipped (semantics don't fit attempt-bounded retry; forcing them would change behavior):

  • withConfigFileLock acquisition — deadline-based wait with interleaved stale-lock takeover
  • savePluginConfig ESTALE CAS loop — re-read/re-merge runs between attempts outside the retryable region
  • storage.ts renameTempToPath — sleeps after the final failed attempt before rethrowing, a schedule withRetry intentionally cannot express

New tests: test/fs-retry.test.ts (18 cases) — first-try success, retry-then-success, exhaustion rethrow, non-retryable immediate throw, fake-timer backoff schedules (including jitter and the shared 25/50/100/200/400ms ladder), zero-delay paths, onRetry, and the sync variant.

Validation

  • npm run typecheck
  • npx eslint <all touched files> --max-warnings=0
  • Migrated-module suites (config ×7, quota-cache, recovery-storage, recovery, uninstall ×3, fs-retry): 14 files, 375/375 passed
  • test/storage.test.ts and test/storage-recovery-paths.test.ts: failure lists diffed before (clean base) vs after — identical; all failures are the known container sandbox-EACCES environment issues documented in docs/audits/evidence/test-baseline-2026-06-10.txt
  • Independently re-verified: typecheck + fs-retry/quota-cache suites, 34/34

Risk / Rollback

Single-commit mechanical migration; revert to roll back. The three skipped sites are unchanged on purpose and listed above for the next pass.

https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB


Generated by Claude Code

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

this pr consolidates 14 hand-rolled file-retry loops across 6 modules into a single withRetry / withRetrySync helper in lib/fs-retry.ts. the migration is mechanical: every call site's attempt count, backoff formula, retryable code set, and error-propagation contract is preserved.

  • new api (withRetry, withRetrySync, RetryOptions) replaces per-module duplicates; withFileOperationRetry is now a thin delegate with identical behavior.
  • backoff translations from 0-based to 1-based attempt indices are all consistent: 10 * 2 ** attempt (0-based) ↔ 10 * 2 ** (attempt - 1) (1-based), verified across every migrated site.
  • error-propagation semantics — ENOENT, non-retryable codes, and exhaustion paths — are semantically equivalent in all migrated functions, including the ENOENT-inside-operation pattern in getConfigFileMtimeMs and the best-effort-swallow pattern in unlinkConfigLockWithRetry.

Confidence Score: 5/5

mechanical migration only — every backoff schedule, attempt count, retryable code set, and error-propagation contract has been faithfully preserved across all 14 migrated sites; no new logic is introduced.

all 0-based-to-1-based backoff conversions are verified correct; ENOENT and non-retryable errors still escape immediately at every call site; the three skipped sites are unchanged and explicitly documented; 375/375 migrated-module tests pass; the only open items were raised in prior review cycles and are all non-blocking.

no files require special attention; lib/fs-retry.ts is the new shared owner of retry semantics and is well covered by the new test suite.

Important Files Changed

Filename Overview
lib/fs-retry.ts new withRetry / withRetrySync / RetryOptions api added; isRetryableError helper uses a slightly wider object guard than the existing shouldRetryFileOperation (no instanceof Error check), but no practical impact since all fs errors are Error instances
lib/config.ts 5 loops migrated; backoff formulas correct; ENOENT handled inside the operation for getConfigFileMtimeMs (returns null, not throws); readConfigRecordForSave exhaustion correctly produces { status: 'unreadable' } via the typeof code === 'string' guard in the outer catch
lib/storage.ts 2 loops migrated; copyFileWithRetry ENOENT still handled on first throw (ENOENT not in FILE_RETRY_CODES so withRetry rethrows immediately, outer catch silences when allowMissingSource=true); jitter on renameFileWithRetry preserved via jitterMs: BACKUP_COPY_BASE_DELAY_MS
lib/recovery/storage.ts 2 sync loops migrated to withRetrySync; local RETRYABLE_FS_CODES (no EACCES) correctly passed as retryableCodes so the recovery module's narrower code set is preserved; ENOENT/exhaustion share a catch block (noted in prior review)
lib/quota-cache.ts local RETRYABLE_FS_CODES duplicate and isRetryableFsError helper removed; both loops now use withRetry with default FILE_RETRY_CODES, aligning with the comment already in the old code requesting this consistency
lib/codex-manager/commands/uninstall.ts local withFileOperationRetry duplicate deleted; now imports from lib/fs-retry.ts; schedule (6 attempts, 25ms exponential + 20ms jitter, FILE_RETRY_CODES) is identical to what was hardcoded locally
test/fs-retry.test.ts 18 test cases added; covers first-try success, retry-then-success, exhaustion rethrow, non-retryable immediate throw, fake-timer backoff/jitter verification, zero-delay path, onRetry callback, and withRetrySync basic paths; withRetrySync busy-wait with real delays (10–40ms) remains untested (noted in prior review)

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["caller: operation()"] --> B["withRetry / withRetrySync"]
    B --> C{"attempt\nsucceeds?"}
    C -- yes --> D["return result"]
    C -- no --> E{"isRetryableError?\n(code in retryableCodes)"}
    E -- no --> F["rethrow immediately"]
    E -- yes --> G{"attempt >=\nmaxAttempts?"}
    G -- yes --> F
    G -- no --> H["onRetry?.(error, attempt)"]
    H --> I["computeDelayMs\n(backoffMs + jitter)"]
    I --> J{"delayMs > 0?"}
    J -- no --> B
    J -- yes --> K["async: await sleep(ms)\nsync: busy-wait loop"]
    K --> B

    subgraph callers["migrated call sites"]
        L["lib/config.ts ×4"]
        M["lib/storage.ts ×2"]
        N["lib/quota-cache.ts ×2"]
        O["lib/recovery/storage.ts ×2"]
        P["lib/codex-manager/uninstall.ts"]
    end

    callers --> B
Loading

Comments Outside Diff (2)

  1. test/fs-retry.test.ts, line 929-993 (link)

    P2 withRetrySync busy-wait path untested

    the withRetrySync suite only exercises backoffMs: 0 and backoffMs: 1; neither path enters the while (Date.now() < waitUntil) busy-wait. on windows, Date.now() resolution can be ~15ms, so a test that passes backoffMs: 10 with a spy on Date.now() (or a real-elapsed assertion) would give confidence that the spin loop terminates correctly and actually waits the expected budget. the production callers in lib/recovery/storage.ts use 10–40ms schedules, which makes this the highest-risk uncovered path in the new helper.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: test/fs-retry.test.ts
    Line: 929-993
    
    Comment:
    **`withRetrySync` busy-wait path untested**
    
    the `withRetrySync` suite only exercises `backoffMs: 0` and `backoffMs: 1`; neither path enters the `while (Date.now() < waitUntil)` busy-wait. on windows, `Date.now()` resolution can be ~15ms, so a test that passes `backoffMs: 10` with a spy on `Date.now()` (or a real-elapsed assertion) would give confidence that the spin loop terminates correctly and actually waits the expected budget. the production callers in `lib/recovery/storage.ts` use 10–40ms schedules, which makes this the highest-risk uncovered path in the new helper.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

  2. lib/config.ts, line 270-310 (link)

    P2 exhaustion error message format changed — not purely mechanical

    the PR description claims "every call site keeps its exact … error propagation." in readConfigRecordForSave, when all 5 attempts on a retryable code (EBUSY/EPERM/EAGAIN) are exhausted and the old loop fell through, logConfigWarnOnce received "Failed to read config from ${configPath}." (no original error detail). the new catch block formats it as "Failed to read config from ${configPath}: ${error.message}". this is a strictly more useful message, but it is a behavioral delta from the "mechanical only" claim and could surprise snapshot tests or log parsers that expect the old format.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: lib/config.ts
    Line: 270-310
    
    Comment:
    **exhaustion error message format changed — not purely mechanical**
    
    the PR description claims "every call site keeps its exact … error propagation." in `readConfigRecordForSave`, when all 5 attempts on a retryable code (EBUSY/EPERM/EAGAIN) are exhausted and the old loop fell through, `logConfigWarnOnce` received `"Failed to read config from ${configPath}."` (no original error detail). the new catch block formats it as `"Failed to read config from ${configPath}: ${error.message}"`. this is a strictly more useful message, but it is a behavioral delta from the "mechanical only" claim and could surprise snapshot tests or log parsers that expect the old format.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Codex

Reviews (2): Last reviewed commit: "refactor(recovery): keep the ENOENT bran..." | Re-trigger Greptile

…hRetry

Add generic withRetry/withRetrySync helpers to lib/fs-retry.ts (per-call
maxAttempts, fixed or per-attempt backoff schedules, optional jitter,
per-site retryable code sets defaulting to FILE_RETRY_CODES, onRetry hook;
zero-delay retries schedule no timer) and migrate 14 hand-rolled retry
loops onto them with per-site attempt counts, backoff schedules, retryable
code sets, and error propagation preserved exactly:

- lib/fs-retry.ts: withFileOperationRetry now delegates to withRetry
- lib/config.ts: readFileSyncWithConfigRetry, getConfigFileMtimeMs,
  writeJsonFileAtomicWithRetry rename loop, unlinkConfigLockWithRetry,
  readConfigRecordForSave (the dead post-loop generic-message block was
  unreachable and is dropped)
- lib/storage.ts: copyFileWithRetry, renameFileWithRetry
- lib/quota-cache.ts: readCacheFileWithRetry, saveQuotaCache rename loop
  (local RETRYABLE_FS_CODES duplicate of FILE_RETRY_CODES removed)
- lib/recovery/storage.ts: renameSyncWithRetry, safeUnlinkWithRetry
  (via withRetrySync busy-wait)
- lib/codex-manager/commands/uninstall.ts: local withFileOperationRetry
  duplicate replaced by the shared lib/fs-retry.ts export (identical
  schedule and code set)

Skipped (semantics that withRetry cannot express without contortion):
- lib/config.ts withConfigFileLock acquisition loop: deadline-based wait
  with interleaved stale-lock takeover, not an attempt-bounded retry
- lib/config.ts savePluginConfig ESTALE CAS loop: only the write is
  guarded; the re-read/re-merge runs between attempts outside the
  retryable region
- lib/storage.ts renameTempToPath: sleeps after the final failed attempt
  before rethrowing (10..160ms), a schedule withRetry deliberately does
  not reproduce

New unit coverage for withRetry/withRetrySync/withFileOperationRetry in
test/fs-retry.test.ts (18 tests). No attempt counts or timings tuned;
audit roadmap section 4.2.

https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@ndycode, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 5 minutes and 57 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b4797ef6-2deb-4b12-ac22-d481879a368c

📥 Commits

Reviewing files that changed from the base of the PR and between 98d9819 and 5a31cca.

📒 Files selected for processing (7)
  • lib/codex-manager/commands/uninstall.ts
  • lib/config.ts
  • lib/fs-retry.ts
  • lib/quota-cache.ts
  • lib/recovery/storage.ts
  • lib/storage.ts
  • test/fs-retry.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/audit-07-retry-consolidation
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/audit-07-retry-consolidation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread lib/recovery/storage.ts
…Retry

Review follow-up: the migration to withRetrySync collapsed the explicit
ENOENT guard into a generic catch. Behavior was identical, but the
already-gone and exhausted-retries cases are worth keeping separable as
the seam for future telemetry.

https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB
@ndycode
ndycode merged commit 2e10505 into main Jun 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants