Skip to content

Feat: Network Resilience Retry Timeout - #35

Draft
tcsizmadia wants to merge 3 commits into
berget-ai:mainfrom
tcsizmadia:feat/network-resilience-retry-timeout
Draft

Feat: Network Resilience Retry Timeout#35
tcsizmadia wants to merge 3 commits into
berget-ai:mainfrom
tcsizmadia:feat/network-resilience-retry-timeout

Conversation

@tcsizmadia

Copy link
Copy Markdown

Summary

Add retry with exponential backoff and request timeouts to all Berget API fetch calls, preventing transient network errors (ECONNRESET, ETIMEDOUT, socket hang up) from surfacing as fatal "connection reset by server" failures.

Context & Motivation

Problem: Users experience "connection reset by server" errors when using the Berget AI provider. Other providers remain stable on the same machines, indicating the issue is in the plugin's transport layer — not the user's network. The plugin's fetchWithAuth wrapper called raw fetch() with zero retry, zero timeout, and zero error classification. A single ECONNRESET killed the entire request.

Why now: The plugin is the sole transport layer between OpenCode and Berget (unlike providers that use OpenCode's built-in HTTP client). Every API request — chat completions, streaming, token refresh, model discovery — flows through the plugin's custom fetch. Network resilience is not optional for an AI inference provider proxy.

Root causes identified:

  1. fetchWithAuth (src/plugin.ts:36) — bare fetch() with no retry or timeout
  2. refreshAccessTokenInternal (src/plugin/token.ts:182) — treated ECONNRESET as permanent failure during token refresh
  3. No AbortSignal.timeout on any fetch call — requests hung indefinitely on half-open connections
  4. fetchBergetModels (src/plugin/models.ts:42) — no retry, silently fell back to stale defaults

Solution

Introduced a new resilientFetch wrapper (src/plugin/resilient-fetch.ts) that replaces all raw fetch() calls in the plugin. The wrapper is transparent — it has the same signature as fetch() and returns a standard Response.

Retry strategy:

  • Exponential backoff with jitter (base 500ms, max 8s, 3 retries)
  • Jitter prevents thundering herd when multiple clients retry simultaneously
  • Configurable via ResilientFetchOptions for callers that need different behavior

What gets retried:

  • Network errors: ECONNRESET, ETIMEDOUT, ECONNABORTED, EPIPE, ENOTFOUND, EAI_AGAIN, socket hang up, "Failed to fetch"
  • HTTP status codes: 408 (Request Timeout), 429 (Too Many Requests), 502/503/504 (Gateway errors)

What does NOT get retried:

  • User-initiated aborts (AbortError)
  • Client errors (4xx except 408/429)
  • Successful responses (2xx)

Timeout: 30s default via AbortSignal.timeout(), composable with caller-provided abort signals via AbortSignal.any().

Changes

Features/Major Changes

  • resilientFetch (src/plugin/resilient-fetch.ts) — New fetch wrapper with retry, timeout, and error classification. Self-contained module with no external dependencies.
  • fetchWithAuth (src/plugin.ts:36) — Now delegates to resilientFetch instead of raw fetch(). Every API request to Berget (chat completions, streaming) automatically retries on transient errors.
  • refreshAccessTokenInternal (src/plugin/token.ts:182) — Uses resilientFetch for the token refresh HTTP call. Network errors during token refresh now retry instead of immediately failing.
  • fetchBergetModels (src/plugin/models.ts:42) — Uses resilientFetch for model discovery. Transient failures during startup no longer silently fall back to stale defaults.

Bug Fixes/Minor Changes

  • handleErrorResponse (src/plugin/token.ts:94) — Removed redundant 5xx retry logic (now handled at transport layer by resilientFetch). This function now only handles domain-level errors (invalid_grant, invalid_token).
  • refreshAccessTokenInternal (src/plugin/token.ts:211) — Changed console.error to logError for consistent debug logging.

Testing

  • Unit tests added/updated
  • All 79 tests passing (npm test)
  • Typecheck clean (npm run typecheck)
  • Lint clean (npm run lint)
  • Format clean (npm run format:check)

Test coverage:

  • resilient-fetch.test.ts — 20 test cases covering:
    • Retry on ECONNRESET, ETIMEDOUT, ECONNABORTED, socket hang up, "Failed to fetch"
    • Retry on HTTP 408, 429, 502, 503, 504
    • No retry on 400, 401, 404 (client errors)
    • No retry on AbortError (user abort)
    • Max retry exhaustion for persistent errors
    • Request option passthrough
    • Default retry configuration
  • token.test.ts — Updated timing expectations for jitter-based backoff
  • plugin.test.ts — Existing tests pass without modification (fetch mock transparently works with resilientFetch)

Review Focus

  1. resilientFetch (src/plugin/resilient-fetch.ts:65) — Core retry loop. Verify the retry/skip logic for both error types (thrown errors vs retryable HTTP statuses).
  2. isRetryableError (src/plugin/resilient-fetch.ts:149) — Error classification. Ensure we don't retry non-transient errors (e.g., DNS resolution failures that are permanent).
  3. fetchWithTimeout (src/plugin/resilient-fetch.ts:127) — Signal composition. Verify AbortSignal.any() correctly merges caller signal with timeout signal.
  4. handleErrorResponse (src/plugin/token.ts:94) — Confirm the 5xx retry removal doesn't break the invalid_grant recovery flow.

Breaking Changes

None. resilientFetch is a drop-in replacement for fetch() with the same signature and return type. The plugin's external API is unchanged.

Deployment Notes

Standard deployment. The new resilient-fetch.ts module has no external dependencies — it uses only Node.js built-in APIs (fetch, AbortSignal, setTimeout).

Additional Context

  • Retry patterns follow industry best practices (Stripe, Google Cloud APIs) — exponential backoff with jitter
  • Debug logging (OPENCODE_BERGET_DEBUG=1) now shows retry attempts: Network error on attempt 1/4: read ECONNRESET, retrying in 387ms
  • The ResilientFetchOptions interface is exported for future callers that need custom retry behavior

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.

1 participant