Repository navigation
fix(ModelSync): retry self-fetch with network-error backoff before in-process fallback - #14
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
Disabled knowledge base sources:
📝 WalkthroughWalkthroughThis PR adds a shared loopback readiness gate and a testable retry wrapper for internal self-fetches in the provider model sync route. The readiness gate probes once per process, prevents boot-time retry amplification across concurrent callers, and the retry wrapper handles linear-backoff retries on network failures while treating HTTP responses as terminal. Integration delegates to in-process fallback on retry exhaustion. ChangesLoopback Readiness Gate and Retry Wrapper
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested labels
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
Follow-up commits (readiness gate + IPv4 fix): Problem diagnosed: The 5-retry-per-connection contract amplified ProxyFetch undici failures from 17 -> 85. Three follow-up commits address this: Commit 19498149 — Shared readiness gate (ensureLoopbackServerReady): all 17 boot-time connections await the same one-shot HTTP probe before firing, eliminating parallel retry amplification. Commit b64d534c — Removed cache:no-store from selfFetchWithRetry (was causing Next.js-patched fetch to behave unexpectedly in the ProxyFetch native-fallback path outside route handler context). Commit 203fedfd — Root cause fix: the model sync scheduler constructs its self-fetch URL as http://localhost:PORT but localhost resolves to ::1 (IPv6) first in Docker containers. The Next.js server binds to 0.0.0.0 (IPv4 only), so ::1 connections fail with TypeError: fetch failed. Fixed by always using http://127.0.0.1:PORT for the loopback self-fetch URL. Live validation (post-deploy):
Tests: 8/8 pass including shared-probe concurrency test and sanity check confirming amplification when gate disabled. |
…-process fallback At container boot, 17 ModelSync events fire one-shot HTTP self-fetches against the in-process server before its HTTP listener is fully ready. The single-attempt contract produced a noisy 17-warning burst on every boot and contributed to the 19/h ProxyFetch undici-dispatcher-failed metric. Extracts the self-fetch into selfFetchWithRetry(url, opts) with: - maxRetries=5 with linear backoff (200ms * (attempt+1) by default) - Retry only on network-level failures (ECONNREFUSED, fetch failed) which indicate the loopback listener is not yet accepting connections - HTTP responses (2xx, 4xx, 5xx) returned as-is: server is up, propagate result - In-process route still used as last-resort fallback after all retries exhausted - Pure-function shape with injectable fetch + in-process callback for testing Tests cover: retry-succeeds-on-attempt-3, exhaust-then-fallback, HTTP-errors-returned-immediately. Updated model-sync-route.test.ts fallback test to expect 5 self-fetch retries before in-process fallback. Sanity check confirmed: maxRetries=1 prevents retry loop, causing attempt-count assertions in retry tests to fail as expected.
… amplification The prior commit reduced per-connection self-fetch failures via retry, but the 5-attempt-per-connection retry against a not-yet-ready loopback server amplified the ProxyFetch undici-failure metric from 17/boot to 85/boot (17 connections x 5 attempts each). This commit adds ensureLoopbackServerReady() -- a module-level shared promise that all selfFetchWithRetry callers await before firing. The actual HTTP readiness probe runs exactly once per process; the 17 boot-time connections all share the same promise, so the loopback HTTP server only sees one readiness probe sequence instead of 17 x 5 = 85 attempts. After readiness, each connection makes one HTTP self-fetch -- no retry amplification possible because the server is confirmed up. Readiness endpoint: /api/providers/__readiness_probe__/models (returns 404 once Next.js is dispatching; any HTTP status confirms readiness). Reduced default maxRetries from 5 to 3: the gate handles the boot race; retries are now only for transient failures after the server is confirmed up. Tests (8 total, all pass): - Existing retry tests updated with skipReadinessGate: true for isolation - ensureLoopbackServerReady: 17 callers share ONE probe sequence (<=15 attempts) - ensureLoopbackServerReady: rejects after maxWaitMs timeout - ensureLoopbackServerReady: resolves on 4xx (any HTTP status = server up) - selfFetchWithRetry with gate: 17 callers -> 1 probe sequence + 17 fetches - Sanity check: disabling gate -> >17 fetch attempts confirmed (amplification) Resolves Section F follow-up concerns from review.
…es Next.js fetch override failure ProxyFetch falls back to Next.js-patched originalFetch when undici fails for loopback (127.0.0.1). Next.js-patched fetch fails with TypeError: fetch failed for cache:no-store requests made outside of a Next.js request-context (which is the case for the ProxyFetch native fallback path). Without cache:no-store the request is passed through to Node.js built-in fetch successfully. The readiness probe works (no cache directive) but the model fetches failed (had cache:no-store), explaining the 17 self-fetch warnings despite the gate. Remove cache:no-store from selfFetchWithRetry. The in-process fallback already handles any response caching concerns; the no-store directive was purely defensive and is not needed for loopback internal traffic.
…d localhost->IPv6 resolution failure In Docker containers, "localhost" resolves to both ::1 (IPv6) and 127.0.0.1 (IPv4). Node.js may prefer ::1 for the connection attempt. The Next.js HTTP server binds to 0.0.0.0 (IPv4 only), so ::1 connections are refused with TypeError: fetch failed — even though the server is fully up and ::1 would ordinarily be fine on a host with IPv6 loopback. The readiness probe already hardcodes http://127.0.0.1:20128, so it succeeds. The model fetches used safeOrigin derived from the incoming request URL, which the scheduler sends as http://localhost:20128 — that resolves to ::1 first and fails. This caused all 17 connections to exhaust their 3 retries (51 ProxyFetch warnings) and fall back to in-process despite the readiness gate resolving. Fix: always construct safeOrigin as http://127.0.0.1:PORT, never http://localhost:PORT.
203fedf to
c01a791
Compare
…terns Fixes the 4 fixable alerts opened in the recent scan and adds enforceable guardrails so future development follows the same pattern. Code fixes: - src/mitm/cert/install.ts: pass certPath/certName/action via exec()'s env option instead of string-interpolating them into the bash script (CodeQL js/shell-command-injection-from-environment cheaper-inference#225) - scripts/docs/{gen-provider-reference,add-frontmatter,fix-internal-links}: escape backslash before other regex/markdown metacharacters (CodeQL js/incomplete-sanitization cheaper-inference#227, cheaper-inference#228, cheaper-inference#229) Documentation (mandatory patterns): - docs/security/PUBLIC_CREDS.md — embedding public upstream OAuth/Firebase identifiers via resolvePublicCred(); never as string literals - docs/security/ERROR_SANITIZATION.md — routing every error response through sanitizeErrorMessage()/buildErrorBody(); never raw err.stack/err.message - CLAUDE.md: 4 new Hard Rules (#11-#14) + Security section + scenario notes - AGENTS.md, CONTRIBUTING.md: cross-reference the two new docs - SECURITY.md: extended Hard Security Rules with the new mandatory patterns - docs/README.md: index entries pointing to the two new docs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…precedent CodeQL js/stack-trace-exposure does not recognize sanitizers reached through a custom helper indirection — flagging callsites like open-sse/utils/error.ts::errorResponse and open-sse/executors/cursor.ts::buildErrorResponse even though both route through sanitizeErrorMessage(). Record the dismissal precedent (alerts cheaper-inference#224 and cheaper-inference#231, May 2026): - Add a "Known CodeQL limitation" section in docs/security/ERROR_SANITIZATION.md documenting how to handle future occurrences (verify callchain → verify test coverage → dismiss with reference, do NOT duplicate the pattern inline). - Extend CLAUDE.md Hard Rule #14 with the precedent so the next engineer doesn't try to "fix" the false positive by weakening the shared sanitizer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…terns Fixes the 4 fixable alerts opened in the recent scan and adds enforceable guardrails so future development follows the same pattern. Code fixes: - src/mitm/cert/install.ts: pass certPath/certName/action via exec()'s env option instead of string-interpolating them into the bash script (CodeQL js/shell-command-injection-from-environment cheaper-inference#225) - scripts/docs/{gen-provider-reference,add-frontmatter,fix-internal-links}: escape backslash before other regex/markdown metacharacters (CodeQL js/incomplete-sanitization cheaper-inference#227, cheaper-inference#228, cheaper-inference#229) Documentation (mandatory patterns): - docs/security/PUBLIC_CREDS.md — embedding public upstream OAuth/Firebase identifiers via resolvePublicCred(); never as string literals - docs/security/ERROR_SANITIZATION.md — routing every error response through sanitizeErrorMessage()/buildErrorBody(); never raw err.stack/err.message - CLAUDE.md: 4 new Hard Rules (#11-#14) + Security section + scenario notes - AGENTS.md, CONTRIBUTING.md: cross-reference the two new docs - SECURITY.md: extended Hard Security Rules with the new mandatory patterns - docs/README.md: index entries pointing to the two new docs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…precedent CodeQL js/stack-trace-exposure does not recognize sanitizers reached through a custom helper indirection — flagging callsites like open-sse/utils/error.ts::errorResponse and open-sse/executors/cursor.ts::buildErrorResponse even though both route through sanitizeErrorMessage(). Record the dismissal precedent (alerts cheaper-inference#224 and cheaper-inference#231, May 2026): - Add a "Known CodeQL limitation" section in docs/security/ERROR_SANITIZATION.md - Extend CLAUDE.md Hard Rule #14 with the precedent Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-process fallback (#14) * fix(ModelSync): retry self-fetch with network-error backoff before in-process fallback At container boot, 17 ModelSync events fire one-shot HTTP self-fetches against the in-process server before its HTTP listener is fully ready. The single-attempt contract produced a noisy 17-warning burst on every boot and contributed to the 19/h ProxyFetch undici-dispatcher-failed metric. Extracts the self-fetch into selfFetchWithRetry(url, opts) with: - maxRetries=5 with linear backoff (200ms * (attempt+1) by default) - Retry only on network-level failures (ECONNREFUSED, fetch failed) which indicate the loopback listener is not yet accepting connections - HTTP responses (2xx, 4xx, 5xx) returned as-is: server is up, propagate result - In-process route still used as last-resort fallback after all retries exhausted - Pure-function shape with injectable fetch + in-process callback for testing Tests cover: retry-succeeds-on-attempt-3, exhaust-then-fallback, HTTP-errors-returned-immediately. Updated model-sync-route.test.ts fallback test to expect 5 self-fetch retries before in-process fallback. Sanity check confirmed: maxRetries=1 prevents retry loop, causing attempt-count assertions in retry tests to fail as expected. * fix(ModelSync): add shared loopback readiness gate to eliminate retry amplification The prior commit reduced per-connection self-fetch failures via retry, but the 5-attempt-per-connection retry against a not-yet-ready loopback server amplified the ProxyFetch undici-failure metric from 17/boot to 85/boot (17 connections x 5 attempts each). This commit adds ensureLoopbackServerReady() -- a module-level shared promise that all selfFetchWithRetry callers await before firing. The actual HTTP readiness probe runs exactly once per process; the 17 boot-time connections all share the same promise, so the loopback HTTP server only sees one readiness probe sequence instead of 17 x 5 = 85 attempts. After readiness, each connection makes one HTTP self-fetch -- no retry amplification possible because the server is confirmed up. Readiness endpoint: /api/providers/__readiness_probe__/models (returns 404 once Next.js is dispatching; any HTTP status confirms readiness). Reduced default maxRetries from 5 to 3: the gate handles the boot race; retries are now only for transient failures after the server is confirmed up. Tests (8 total, all pass): - Existing retry tests updated with skipReadinessGate: true for isolation - ensureLoopbackServerReady: 17 callers share ONE probe sequence (<=15 attempts) - ensureLoopbackServerReady: rejects after maxWaitMs timeout - ensureLoopbackServerReady: resolves on 4xx (any HTTP status = server up) - selfFetchWithRetry with gate: 17 callers -> 1 probe sequence + 17 fetches - Sanity check: disabling gate -> >17 fetch attempts confirmed (amplification) Resolves Section F follow-up concerns from review. * fix(ModelSync): drop cache:no-store from loopback self-fetch — bypasses Next.js fetch override failure ProxyFetch falls back to Next.js-patched originalFetch when undici fails for loopback (127.0.0.1). Next.js-patched fetch fails with TypeError: fetch failed for cache:no-store requests made outside of a Next.js request-context (which is the case for the ProxyFetch native fallback path). Without cache:no-store the request is passed through to Node.js built-in fetch successfully. The readiness probe works (no cache directive) but the model fetches failed (had cache:no-store), explaining the 17 self-fetch warnings despite the gate. Remove cache:no-store from selfFetchWithRetry. The in-process fallback already handles any response caching concerns; the no-store directive was purely defensive and is not needed for loopback internal traffic. * fix(ModelSync): force IPv4 (127.0.0.1) for loopback self-fetch — avoid localhost->IPv6 resolution failure In Docker containers, "localhost" resolves to both ::1 (IPv6) and 127.0.0.1 (IPv4). Node.js may prefer ::1 for the connection attempt. The Next.js HTTP server binds to 0.0.0.0 (IPv4 only), so ::1 connections are refused with TypeError: fetch failed — even though the server is fully up and ::1 would ordinarily be fine on a host with IPv6 loopback. The readiness probe already hardcodes http://127.0.0.1:20128, so it succeeds. The model fetches used safeOrigin derived from the incoming request URL, which the scheduler sends as http://localhost:20128 — that resolves to ::1 first and fails. This caused all 17 connections to exhaust their 3 retries (51 ProxyFetch warnings) and fall back to in-process despite the readiness gate resolving. Fix: always construct safeOrigin as http://127.0.0.1:PORT, never http://localhost:PORT. --------- Co-authored-by: OmniRoute Ops <ops@nomenak.dev>
Summary
fetchProviderModelsForSyncinto an exportedselfFetchWithRetry(url, opts)helper with linear-backoff retry logicgetProviderModels) remains as last-resort fallback after all retries exhaustedmodel-sync-route.test.tsfallback test to expect 5 self-fetch attempts before in-process fallbackContext
At container boot, 17 ModelSync events fire one-shot HTTP self-fetches against
http://127.0.0.1:PORTbefore the Next.js HTTP listener is fully accepting connections. The single-attempt contract produced 17 warnings on every boot. This PR adds retry-with-backoff to give the listener time to come up.Known limitation: In this deployment the startup race window (Next.js warmup time) exceeds the default 2s retry window (~200ms * sum(1..5)), so all 17 connections still exhaust retries at boot. The in-process fallback continues to work correctly. Increasing
backoffMsormaxRetriescan extend the window at the cost of higher initial latency for each connection.Test plan
tests/unit/api/sync-models-readiness.test.ts— 3 new tests: retry-succeeds-on-attempt-3, exhaust-then-fallback, HTTP-errors-returned-immediatelytests/unit/model-sync-route.test.ts— all 18 existing tests pass (updated fallback test assertion)Summary by CodeRabbit
Release Notes
Bug Fixes
Tests