Skip to content

fix(models): retry transient request failures in VercelModel and GoogleModel - #5663

Merged
opieter-aws merged 2 commits into
strands-agents:mainfrom
opieter-aws:opieter-aws/vercelmodel-failed-fetch-fix
Nov 3, 2026
Merged

opieter-aws merged 2 commits into
strands-agents:mainfrom
opieter-aws:opieter-aws/vercelmodel-failed-fetch-fix

Conversation

@opieter-aws

@opieter-aws opieter-aws commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Human Overview

Description

integ-browser keeps failing with ModelError: Language model stream error: Failed to fetch in VercelModel (Bedrock) tests. It hit 3 of the last ~13 full TS integ runs, including the main release run 40931625672. The cause is a short network drop between the runner's Chromium and Bedrock. Bedrock, Anthropic and OpenAI survive these drops because their vendor SDKs retry connection failures. VercelModel calls doStream() directly (the AI SDK only retries inside streamText), and @google/genai only retries when you opt in. So for these two providers a dropped connection surfaces as a plain ModelError, which the agent's retry strategy ignores.

VercelModel and GoogleModel now throw a new ModelNetworkError for fetch network failures, both when connecting and mid-stream. DefaultModelRetryStrategy retries it alongside ModelThrottledError. All retrying stays in the agent's retryStrategy, where hooks and ModelRouter fallback can see it, instead of a second loop inside the provider. HTTP error responses are classified as before; a Vercel 5xx is still a ModelError.

Public API Changes

New exported ModelNetworkError extends ModelError, retried by the default strategy:

import { ModelNetworkError } from '@strands-agents/sdk'

try {
  await agent.invoke('What time is it?')
} catch (error) {
  if (error instanceof ModelNetworkError) {
    // the connection kept failing after every retry attempt
  }
}

Network failures from these two providers are now retried with the default budget (6 attempts, exponential backoff from 4s) before they surface. The error message is unchanged. To opt out, override isRetryable, or pass retryStrategy: null to disable retries.

Related Issues

None. Found while investigating the flaky integ-browser failures linked above.

Documentation PR

Not included yet. Two TypeScript docs statements need updating in a follow-up site PR. agents/retry-strategies.mdx says the default retries ModelThrottledError only. The error-classification bullet in model-providers/vercel.mdx doesn't list the new error.

Type of Change

Bug fix

Testing

Ran the full TS unit suites in Node and the browser, plus lint, type-check, format and complexity. Ran the integ-browser VercelModel (Bedrock) interventions suite locally: 52/52 pass. End to end against real Bedrock (script below), a fetch that throws TypeError('Failed to fetch') on the model call after a denied tool reproduces the CI failure. The default strategy retries once and the agent finishes with endTurn. With retryStrategy: null the run fails with ModelNetworkError. GoogleModel's classification is covered by unit tests only; I didn't run it against the live Gemini API.

  • I ran hatch run prepare (n/a, TypeScript-only)

Additional Details

Extended context (optional)

Earlier revision. The first commit on this branch added a provider-level retry loop with a maxRetries option. Review pointed out that it duplicated DefaultModelRetryStrategy, so this revision replaces it with classification only.

Naming. It's ModelNetworkError, not NetworkError, because DOMException already uses NetworkError as a name (and Firefox's fetch failure message starts with it). It also follows the SDK's Model…Error convention. It covers network failures only. Folding retryable 5xx responses in would make the name wrong, so those keep today's classification.

Mid-stream failures. These are now retried too. One CI run dropped the connection mid-stream with Chromium's TypeError: network error. The agent re-runs the whole model call, and events already streamed have already been emitted. That's the same as a mid-stream ModelThrottledError today.

GoogleModel. The error if ladder in the stream catch is now a lookup table keyed on GoogleErrorType, so adding an error type is a compile error until it gets a mapping. This also keeps stream()'s complexity from rising. Google's existing 503 UNAVAILABLE → ModelThrottledError mapping is unchanged.

Why not genai's own retryOptions. With it set, genai turns non-OK responses into generic errors before throwErrorIfNotOK runs. That would hide throttling and context-overflow detection from classifyGoogleError.

CI evidence. Failed to fetch showed up in 0 of ~25 runs before 2026-10-29 01:21Z and in 3 of ~13 since then. Each failure hit a different deny test. In each case a sibling test running at the same time also failed its first attempt and passed on vitest's immediate retry, which points to a network blip. One failing branch's merge-base had passed many earlier runs, so no SDK commit caused it.

Follow-ups. Python parity: Python's retry strategy also only retries ModelThrottledException, and its Gemini provider has the same gap. The same run's agent.test.ts > AnthropicModel > handles tool invocation browser timeout is unrelated.

E2E script (repo root .context/, run with npx tsx from strands-ts/):

let fetchCalls = 0
const flakyFetch: typeof fetch = async (input, init) => {
  fetchCalls++
  // Fail the second model call (the one after the denied tool), as in the CI failure.
  if (fetchCalls === 2) throw new TypeError('Failed to fetch')
  return fetch(input, init)
}
const provider = createAmazonBedrock({ region: 'us-west-2', credentialProvider, fetch: flakyFetch })
const agent = new Agent({
  model: new VercelModel({ provider: provider('global.anthropic.claude-sonnet-4-6') }),
  tools: [timeTool],
  interventions: [new DenyHandler()],
})
await agent.invoke('What time is it?')
// => stopReason: endTurn, fetchCalls: 3,
//    "retry_delay_ms=<688> attempt_count=<1> max_attempts=<6> | retryable model error, delaying before retry"

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@github-actions github-actions Bot added bug Something isn't working typescript Pull requests that update typescript code area-model Related to models or model providers labels Oct 29, 2026
@opieter-aws

Copy link
Copy Markdown
Contributor Author

@strandly-the-agent review pls

@opieter-aws
opieter-aws marked this pull request as ready for review October 29, 2026 19:31
@opieter-aws
opieter-aws requested a review from a team as a code owner October 29, 2026 19:31
@opieter-aws
opieter-aws requested a review from Unshure October 29, 2026 19:31
@github-actions github-actions Bot added complexity/low Touched functions have low cognitive complexity (<=10) size/m strands-running labels Oct 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Issue: This PR adds a new public config option — maxRetries on both VercelModelConfig and GoogleModelConfig — but the PR isn't tagged for API review (api/needs-review).

Suggestion: Per team/API_BAR_RAISING.md, a new public API field warrants an API bar-raising pass, even for a small additive option. The design looks sound (name mirrors the AI SDK's maxRetries, default 2, 0 opts out, and the TSDoc steers Google users away from httpOptions.retryOptions). Adding the api/needs-review label so a designated reviewer signs off would close the loop. Two small points worth confirming during that review:

  • Default-on changes behavior: the Vercel path now retries not just the network drop this fixes but anything the AI SDK marks retryable (408/409/5xx), adding up to ~6s before the error surfaces. That's reasonable and documented — just flagging it's broader than the stated bug.
  • The two configs document slightly different retry scopes (Vercel retries 408/409/5xx + network; Google retries network only). That asymmetry is intentional and well-justified in the description, but it's worth a note on the doc-site follow-up so users understand the difference.

@github-actions

Copy link
Copy Markdown
Contributor

Assessment: Approve

Well-scoped, well-reasoned fix for the flaky integ-browser Failed to fetch failures. The shared withRequestRetry helper is clean, correctly bounded, abort-aware, and backed by thorough unit tests (whole-object assertions, fake timers, exhausted-budget and abort paths all covered). The retry/no-retry classification is careful: 429 is deliberately left to the agent's retryStrategy to avoid double-retrying, and I confirmed the default strategy only retries ModelThrottledError, so a 5xx surfaced after provider retries won't be retried again.

Review notes
  • API: New public maxRetries on two configs lacks api/needs-review — see inline comment. Design itself looks sound.
  • Correctness: ExponentialBackoff is stateless, so the module-level REQUEST_RETRY_BACKOFF singleton is safe to share across concurrent calls. Abort handling (sleep resolves rather than rejects, loop re-checks signal?.aborted) is correct.
  • Refactor: Moving sleep into backoff-strategy.ts dedupes the copy in model-retry-strategy.ts and avoids the import cycle — good call.
  • Docs: maxRetries doc-site update is deferred to a follow-up (acknowledged); worth tracking so it doesn't get lost.

Nicely done — the error-classification reasoning and the CI/e2e evidence in the description made this easy to review.

@strandly-the-agent strandly-the-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve — sound fix, real tests. One non-blocking nit on where maxRetries is validated.

I re-derived the two dependency claims the fix rests on, and both hold:

  • ai-sdk only converts a fetch TypeError into a retryable APICallError when error.cause != null (@ai-sdk/provider-utils handleFetchError). Chromium's Failed to fetch has no cause, so it reached you raw — isFetchNetworkError is load-bearing in the browser, not belt-and-braces.
  • @google/genai's generateContentStream only awaits the request on the AFC-disabled branch; Strands lands there because it passes plain functionDeclarations (shouldDisableAfc returns true with no CallableTool). So the rejection really is pre-response and withRequestRetry can see it. Worth a one-line comment, since the AFC branch returns the generator eagerly and would make the retry a silent no-op.

Nit (inline): a bad maxRetries is caught only on the first stream(), and in VercelModel it comes back as ModelError: Language model stream error: maxRetries must be an integer >= 0 (got -1).

✅ Verified, questions, appendix

Verified at 00d5136 (base aba77371a)

what result
npx vitest run --project unit-node src/models/__tests__/{model,vercel,google}.test.ts src/retry 318 passed, no type errors
npx vitest run --project unit-browser src/models/__tests__/{model,vercel,google}.test.ts 285 passed (chromium)
full --project unit-node 5759 passed / 2 failed / 6 skipped — both failures are src/telemetry/__tests__/config.test.ts asserting service.name === 'strands-agents' while my sandbox sets OTEL_RESOURCE_ATTRIBUTES. Sandbox artifact, not yours.
eslint on the 9 changed files clean

Behaviour I checked by reading, not by trusting the description:

  • No maxRetries collision. LanguageModelCallSettings is Omit<LanguageModelV3CallOptions, 'prompt'\|'tools'\|'toolChoice'>, and LanguageModelV3CallOptions has no maxRetries (@ai-sdk/provider), so the new field is genuinely new and nothing was previously forwarded under that name. The two "does not forward maxRetries" tests pin it.
  • Shared REQUEST_RETRY_BACKOFF is safe to share across concurrent calls. ExponentialBackoff holds only readonly config and nextDelay is pure (backoff-strategy.ts), so there is no cross-request state.
  • 429 really does reach the agent strategy. classifyError maps statusCode === 429 → ModelThrottledError, and DefaultModelRetryStrategy.isRetryable retries only ModelThrottledError — so nothing is double-retried, and a 5xx that exhausts the in-provider budget is not retried again at the agent level.
  • Cancellation contract survives. Aborting during a backoff makes withRequestRetry throw the last transient error, not a cancel error — but agent.ts:2563-2567 calls _throwIfCancelled() in the catch before rethrowing, so the caller still gets CancelledError. Fine as-is.
  • Tests prove behaviour. The Node and browser retry tests reject a mock once and compare the collected events to a clean run (toEqual(expected)), which is what actually rules out duplicated stream events; maxRetries: 0, exhausted budget, 429, and abort-during-backoff are each pinned by call counts rather than by mock echoes.
  • No new public surface leaked. withRequestRetry / isFetchNetworkError are @internal and absent from src/index.ts; sleep moved into backoff-strategy.ts but src/retry/index.ts uses explicit named exports, so it did not become public by accident.
  • Repo conventions. No site/ docs in this PR is consistent with AGENTS.md ("a branch that touches several sub-projects is almost always several PRs") and you called the follow-up out in the description.

Questions (both non-blocking)

  1. Retries are invisible above the provider. AfterModelCallEvent.attemptCount doesn't count them and there's no span or metric, so a turn that silently absorbed 1–6s looks like one slow model call. Debug log only, intentional for now?
  2. Python Gemini parity. strands-py/src/strands/models/gemini.py has no retry at all and I found no tracking issue for it. Want me to file the parity issue so the follow-up you named doesn't get lost?

Appendix — non-blocking (3)

  • api/needs-review still isn't applied (already raised above, so just one data point): .github/workflows/api-review-label.yml only fails when api/needs-review is present without api/review-complete, so the green check on a PR with neither label is vacuous — it isn't evidence the gate was cleared. For what it's worth the shape has precedent: BedrockModelOptions already carries a top-level requestTimeout that shadows a clientConfig setting.
  • The two maxRetries TSDocs read as one knob with two scopes (Vercel: network + 408/409/5xx; Google: network only). The Google one says "whose connection fails", which is accurate but easy to skim past — a clause at strands-ts/src/models/google/types.ts:57 saying an HTTP error response is never retried would close it, and the doc-site follow-up is a good place for the comparison.
  • startedAt / elapsedMs in withRequestRetry are dead weight today — ExponentialBackoff.nextDelay reads only attempt. Fine as the BackoffContext contract; noting it so it doesn't read as an implemented time budget.

Evidence: vitest-and-lint.log, repro-invalid-maxretries.test.ts, repro-invalid-maxretries.log.

Comment thread strands-ts/src/models/model.ts Resolved
@strandly-the-agent

Copy link
Copy Markdown
Collaborator

Follow-up to my review above: the API bar-raise and adversarial passes are done — nothing new, verdict stays approve. Two things worth having on the thread:

  • No api/needs-review needed. This is a Minimal change per team/API_BAR_RAISING.md:20-21 ("adding a new parameter … PR review is sufficient"), so the label the bot and I pointed at is optional here, not a gate. Sorry for the noise.
  • The retry boundary holds under real sockets, not just mocks — details below.
Adversarial repros (local HTTP server + real fetch, Node 22) and one API nit

All scripts and output are in this PR's artifacts (adversarial-repros.ts, adversarial-repros.log).

scenario Vercel (Bedrock) Google
dead port, default 3 fetches, then ModelError after ~4s 3 fetches, then TypeError: fetch failed after ~5s
dead port, maxRetries: 0 1 fetch 1 fetch
socket reset before headers retried (2 requests) retried (2 requests)
socket reset after headers / mid-body not retried — TypeError: terminated isn't in the message set, and modelMessageStartEvent had already been emitted, so no duplicate events not retried
request body across attempts byte-identical byte-identical
agent.cancel() / cancelSignal during backoff stopReason: cancelled, 1 fetch same
503, 503, 200 recovers in-provider; agent strategy sees 1 attempt —
503 ×3 ModelError after 3 fetches; agent strategy does not retry it again —
429 always 1 fetch per attempt; agent strategy retries ×3 — no double retry —
genai AFC path (a CallableTool in config.tools) — retry does not fire (generateContentStream returns the generator eagerly). Not reachable through builtInTools?: Tool[] without a cast, so just confirming the comment suggestion above.

⚪ API nit (non-blocking): the two maxRetries TSDocs (strands-ts/src/models/vercel.ts:98-105, strands-ts/src/models/google/types.ts:56-64) don't state the cost of the default. The PR body does ("~6s longer"); the place users read doesn't. One sentence — e.g. "Retries back off ~1–2s, then ~2–4s" — would do it.

Comment thread strands-ts/src/errors.ts Resolved
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Re-review at c5e6d7e. This revision removes the maxRetries loop and classifies network failures as ModelNetworkError instead.

Assessment: Comment (not Approve yet).

A correction to my previous review: I said the old maxRetries field needed api/needs-review. That was too strict. It was a Minimal change, as @strandly-the-agent pointed out, and the field is gone now anyway. The inline thread about maxRetries validation is also outdated now. This revision adds a public error class and changes a documented default, so an API review is warranted for those reasons (see inline).

Moving retries into the agent strategy is the better design: hooks and ModelRouter fallback can see them, and there's one retry loop instead of two. The Google lookup table is a nice cleanup too. The open question is scope. String-matching fetch failed / Failed to fetch catches permanent connection failures as well as transient ones, and with the default 6-attempt budget that turns a misconfigured endpoint into a silent wait of a minute or more.

Review notes
  • Classification scope (Important): undici reports ENOTFOUND, ECONNREFUSED and TLS failures all as TypeError: fetch failed (I reproduced this on Node 22), and the browser does the same for CORS. Node's mid-stream terminated is not covered, even though the PR now promises mid-stream retries. Details are inline on errors.ts.
  • API / cross-SDK (Important): there's a new exported ModelNetworkError, and the TypeScript default retry set now differs from Python's and from the shared docs. Details are inline on default-model-retry-strategy.ts.
  • Verified by reading: isNetworkError only matches status-less retryable APICallErrors, so a 503 stays ModelError (and a test pins that). Abort errors are excluded, since ai-sdk's handleFetchError returns abort errors before wrapping and AbortError isn't a TypeError. isFetchNetworkError isn't exported from index.ts. Both doStream and reader.read() route through classifyError, and Google's for await sits inside the classified try.
  • Mid-stream retry: events that were already streamed get emitted again on the retry. That's the same as the existing behaviour for a mid-stream throttle and is acknowledged in the PR, but it's worth a sentence in the docs follow-up for people building streaming UIs.
  • Not run: node_modules isn't installed in this sandbox, so I didn't re-run vitest at this head. The findings above come from reading the code plus the direct fetch repro.

@mkmeral mkmeral added api/needs-review Makes changes to the public API surface api/review-complete An API Bar-raiser reviewed and accepted the APIs labels Nov 2, 2026
@opieter-aws
opieter-aws merged commit bd8c83d into strands-agents:main Nov 3, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api/needs-review Makes changes to the public API surface api/review-complete An API Bar-raiser reviewed and accepted the APIs area-model Related to models or model providers bug Something isn't working complexity/low Touched functions have low cognitive complexity (<=10) size/m typescript Pull requests that update typescript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants