Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions strands-ts/src/__tests__/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
ContextWindowOverflowError,
MaxTokensError,
ModelThrottledError,
ModelNetworkError,
isFetchNetworkError,
normalizeError,
} from '../errors.js'
import { Message, TextBlock } from '../types/messages.js'
Expand Down Expand Up @@ -168,6 +170,42 @@ describe('ModelThrottledError', () => {
})
})

describe('ModelNetworkError', () => {
it('is a ModelError with its own name that preserves the cause', () => {
const cause = new TypeError('Failed to fetch')
const error = new ModelNetworkError('Language model stream error: Failed to fetch', { cause })

expect(error).toBeInstanceOf(ModelError)
expect(error).toMatchObject({
name: 'ModelNetworkError',
message: 'Language model stream error: Failed to fetch',
cause,
})
})
})

describe('isFetchNetworkError', () => {
it.each([
'Failed to fetch',
'network error',
'fetch failed',
'NetworkError when attempting to fetch resource.',
'Load failed',
'Network request failed',
'The Internet connection appears to be offline.',
])('returns true for a TypeError with message %j', (message) => {
expect(isFetchNetworkError(new TypeError(message))).toBe(true)
})

it('returns false for a TypeError with another message', () => {
expect(isFetchNetworkError(new TypeError("Cannot read properties of undefined (reading 'body')"))).toBe(false)
})

it('returns false for a non-TypeError with a network failure message', () => {
expect(isFetchNetworkError(new Error('Failed to fetch'))).toBe(false)
})
})

describe('normalizeError', () => {
describe('when given an Error instance', () => {
it('returns the same Error instance', () => {
Expand Down
15 changes: 14 additions & 1 deletion strands-ts/src/agent/__tests__/agent.model-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { DefaultModelRetryStrategy } from '../../retry/default-model-retry-strat
import { ModelRetryStrategy } from '../../retry/model-retry-strategy.js'
import type { RetryDecision } from '../../retry/retry-strategy.js'
import { ConstantBackoff } from '../../retry/backoff-strategy.js'
import { ModelThrottledError } from '../../errors.js'
import { ModelThrottledError, ModelNetworkError } from '../../errors.js'
import { AfterModelCallEvent } from '../../hooks/events.js'
import { logger } from '../../logging/logger.js'

Expand Down Expand Up @@ -99,6 +99,19 @@ describe('Agent retryStrategy wiring', () => {
expect(result.lastMessage.content[0]).toEqual({ type: 'textBlock', text: 'ok' })
})

it('retries model calls that throw ModelNetworkError with the default strategy', async () => {
const model = new MockMessageModel()
.addTurn(new ModelNetworkError('Language model stream error: Failed to fetch'))
.addTurn({ type: 'textBlock', text: 'ok' })

const agent = new Agent({ model })
const invokePromise = agent.invoke('hi')
await vi.runAllTimersAsync()
const result = await invokePromise

expect(result.lastMessage.content[0]).toEqual({ type: 'textBlock', text: 'ok' })
})

it('gives up once maxAttempts is exceeded', async () => {
const model = new MockMessageModel()
.addTurn(new ModelThrottledError('throttled 1'))
Expand Down
46 changes: 46 additions & 0 deletions strands-ts/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,52 @@ export class ModelThrottledError extends ModelError {
}
}

/**
* Error thrown when the network connection to the model provider fails, before or while the response
* is received.
*
* Retrying the same request may succeed. The default model retry strategy retries it, like
* {@link ModelThrottledError}.
*/
export class ModelNetworkError extends ModelError {
/**
* Creates a new ModelNetworkError.
*
* @param message - Error message describing the network failure
* @param options - Optional error options including cause for error chaining
*/
constructor(message: string, options?: ErrorOptions) {
super(message, options)
this.name = 'ModelNetworkError'
}
}

/**
* Lower-cased `TypeError` messages that `fetch` implementations use when the connection fails.
*/
const FETCH_NETWORK_ERROR_MESSAGES = new Set([
'failed to fetch', // Chromium, before a response
'network error', // Chromium, while reading the response body
'fetch failed', // Node.js (undici)
Comment thread
mkmeral marked this conversation as resolved.
Resolved
'networkerror when attempting to fetch resource', // Firefox
'load failed', // Safari
'network request failed', // React Native
'the internet connection appears to be offline', // Safari, offline
])

/**
* Whether `error` is a `fetch` network failure rather than an HTTP error response.
*
* @param error - The error a request or response read rejected with
* @returns True for the network-failure `TypeError` of any supported runtime
*
* @internal
*/
export function isFetchNetworkError(error: unknown): boolean {
if (!(error instanceof TypeError)) return false
return FETCH_NETWORK_ERROR_MESSAGES.has(error.message.toLowerCase().replace(/\.$/, ''))
}

/**
* Normalizes an unknown error value to an Error instance.
*
Expand Down
1 change: 1 addition & 0 deletions strands-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export {
JsonValidationError,
ConcurrentInvocationError,
ModelThrottledError,
ModelNetworkError,
ToolValidationError,
StructuredOutputError,
ToolNotFoundError,
Expand Down
30 changes: 29 additions & 1 deletion strands-ts/src/models/__tests__/google.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { GoogleGenAI, FunctionCallingConfigMode, type GenerateContentResponse } from '@google/genai'
import { collectIterator } from '../../__fixtures__/model-test-helpers.js'
import { GoogleModel } from '../google/model.js'
import { ContextWindowOverflowError, ModelThrottledError } from '../../errors.js'
import { ContextWindowOverflowError, ModelThrottledError, ModelNetworkError } from '../../errors.js'
import {
Message,
CachePointBlock,
Expand Down Expand Up @@ -551,6 +551,34 @@ describe('GoogleModel', () => {
await expect(collectIterator(provider.stream(messages))).rejects.toThrow(ModelThrottledError)
})

it('throws ModelNetworkError for a fetch network error', async () => {
const networkError = new TypeError('Failed to fetch')
const mockClient = {
models: {
generateContentStream: vi.fn(async () => {
throw networkError
}),
},
} as unknown as GoogleGenAI

const provider = new GoogleModel({ client: mockClient })
const messages = [new Message({ role: 'user', content: [new TextBlock('Hi')] })]

const error = await collectIterator(provider.stream(messages)).catch((caught: unknown) => caught)

expect(error).toBeInstanceOf(ModelNetworkError)
expect(error).toMatchObject({ message: 'Failed to fetch', cause: networkError })
})

it('throws ModelNetworkError when the connection drops while reading the stream', async () => {
const { provider, messages } = setupStreamTest(async function* () {
yield { candidates: [{ content: { parts: [{ text: 'Hel' }] } }] }
throw new TypeError('network error')
})

await expect(collectIterator(provider.stream(messages))).rejects.toThrow(ModelNetworkError)
})

it('rethrows unrecognized errors', async () => {
const mockClient = {
models: {
Expand Down
59 changes: 58 additions & 1 deletion strands-ts/src/models/__tests__/vercel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
} from '@ai-sdk/provider'
import { APICallError } from '@ai-sdk/provider'
import { VercelModel } from '../vercel.js'
import { ContextWindowOverflowError, ModelError, ModelThrottledError } from '../../errors.js'
import { ContextWindowOverflowError, ModelError, ModelThrottledError, ModelNetworkError } from '../../errors.js'
import { logger } from '../../logging/logger.js'
import { warnOnce } from '../../logging/warn-once.js'
import { collectIterator } from '../../__fixtures__/model-test-helpers.js'
Expand Down Expand Up @@ -470,6 +470,48 @@ describe('VercelModel', () => {
await expect(collectIterator(model.stream([]))).rejects.toThrow(ModelThrottledError)
})

it('throws ModelNetworkError when doStream fails with a fetch network error', async () => {
const { mock, model } = setupCaptureTest()
const networkError = new TypeError('Failed to fetch')
;(mock.doStream as ReturnType<typeof vi.fn>).mockRejectedValue(networkError)

const error = await collectIterator(model.stream([])).catch((caught: unknown) => caught)

expect(error).toBeInstanceOf(ModelNetworkError)
expect(error).toMatchObject({ message: 'Language model stream error: Failed to fetch', cause: networkError })
})

it('throws ModelNetworkError for the APICallError ai-sdk raises when it cannot connect', async () => {
const { mock, model } = setupCaptureTest()
;(mock.doStream as ReturnType<typeof vi.fn>).mockRejectedValue(
new APICallError({
message: 'Cannot connect to API: other side closed',
url: 'https://api.example.com',
requestBodyValues: {},
isRetryable: true,
})
)

await expect(collectIterator(model.stream([]))).rejects.toThrow(ModelNetworkError)
})

it('throws a plain ModelError for a server error response', async () => {
const { mock, model } = setupCaptureTest()
;(mock.doStream as ReturnType<typeof vi.fn>).mockRejectedValue(
new APICallError({
message: 'Service Unavailable',
url: 'https://api.example.com',
requestBodyValues: {},
statusCode: 503,
})
)

const error = await collectIterator(model.stream([])).catch((caught: unknown) => caught)

expect(error).toBeInstanceOf(ModelError)
expect(error).not.toBeInstanceOf(ModelNetworkError)
})

it('throws ContextWindowOverflowError for APICallError with context overflow in responseBody', async () => {
const { mock, model } = setupCaptureTest()
;(mock.doStream as ReturnType<typeof vi.fn>).mockRejectedValue(
Expand Down Expand Up @@ -515,6 +557,21 @@ describe('VercelModel', () => {

await expect(collectIterator(model.stream([]))).rejects.toThrow(ModelThrottledError)
})

it('throws ModelNetworkError when the connection drops while reading the stream', async () => {
const mock = createMockModel([])
;(mock.doStream as ReturnType<typeof vi.fn>).mockResolvedValue({
stream: new ReadableStream({
start(controller) {
controller.enqueue({ type: 'stream-start', warnings: [] })
controller.error(new TypeError('network error'))
},
}),
})
const model = new VercelModel({ provider: mock })

await expect(collectIterator(model.stream([]))).rejects.toThrow(ModelNetworkError)
})
})

describe('call options forwarding', () => {
Expand Down
7 changes: 6 additions & 1 deletion strands-ts/src/models/google/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* @internal This module is not part of the public API.
*/

import { isFetchNetworkError } from '../../errors.js'
import { logger } from '../../logging/logger.js'

/**
Expand All @@ -12,7 +13,7 @@ import { logger } from '../../logging/logger.js'
* This union type will expand as more error types are supported
* (e.g., 'throttling', 'invalidRequest').
*/
export type GoogleErrorType = 'contextOverflow' | 'throttling'
export type GoogleErrorType = 'contextOverflow' | 'throttling' | 'network'

/**
* Configuration for handling a specific error status.
Expand Down Expand Up @@ -51,6 +52,10 @@ export const ERROR_STATUS_MAP: Record<string, ErrorStatusConfig> = {
* @internal
*/
export function classifyGoogleError(error: Error): GoogleErrorType | undefined {
if (isFetchNetworkError(error)) {
return 'network'
}

if (!error.message) {
return undefined
}
Expand Down
29 changes: 17 additions & 12 deletions strands-ts/src/models/google/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,29 @@ import { Model, resolveConfigMetadata } from '../model.js'
import type { CountTokensOptions, StreamOptions } from '../model.js'
import type { Message } from '../../types/messages.js'
import type { ModelStreamEvent, Usage } from '../streaming.js'
import { ContextWindowOverflowError, ModelThrottledError, ProviderTokenCountError } from '../../errors.js'
import {
ContextWindowOverflowError,
ModelThrottledError,
ModelNetworkError,
ProviderTokenCountError,
} from '../../errors.js'
import type { GoogleModelConfig, GoogleModelOptions, GoogleStreamState } from './types.js'
export type { GoogleModelConfig, GoogleModelOptions }
import { classifyGoogleError } from './errors.js'
import { classifyGoogleError, type GoogleErrorType } from './errors.js'
import { formatMessages, mapChunkToEvents } from './adapters.js'
import { MODEL_DEFAULTS, defaultModelWarningMessage } from '../defaults.js'
import { warnOnce } from '../../logging/warn-once.js'
import { logger } from '../../logging/logger.js'

/**
* Builds the Strands error for each recognized Google error type.
*/
const GOOGLE_ERROR_FACTORIES: Record<GoogleErrorType, (error: Error) => Error> = {
contextOverflow: (error) => new ContextWindowOverflowError(error.message),
throttling: (error) => new ModelThrottledError(error.message, { cause: error }),
network: (error) => new ModelNetworkError(error.message, { cause: error }),
}

/**
* Google model provider implementation.
*
Expand Down Expand Up @@ -264,16 +278,7 @@ export class GoogleModel extends Model<GoogleModelConfig> {
throw error
}
const errorType = classifyGoogleError(error)

if (errorType === 'contextOverflow') {
throw new ContextWindowOverflowError(error.message)
}

if (errorType === 'throttling') {
throw new ModelThrottledError(error.message, { cause: error })
}

throw error
throw errorType ? GOOGLE_ERROR_FACTORIES[errorType](error) : error
}
}

Expand Down
23 changes: 21 additions & 2 deletions strands-ts/src/models/vercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ import {
ModelMessageStopEvent,
ModelMetadataEvent,
} from './streaming.js'
import { ContextWindowOverflowError, ModelError, ModelThrottledError } from '../errors.js'
import {
ContextWindowOverflowError,
ModelError,
ModelThrottledError,
ModelNetworkError,
isFetchNetworkError,
} from '../errors.js'
import { toMimeType } from '../mime.js'
import { logger } from '../logging/logger.js'
import { warnOnce } from '../logging/warn-once.js'
Expand Down Expand Up @@ -548,7 +554,7 @@ function applyOpenAICache(callOptions: LanguageModelV3CallOptions, cacheConfig:
* Classifies an error from doStream into the appropriate Strands error type.
*
* @param error - The error thrown by the Vercel provider
* @returns A classified error (ContextWindowOverflowError, ModelThrottledError, or ModelError)
* @returns A classified error (ContextWindowOverflowError, ModelThrottledError, ModelNetworkError, or ModelError)
*/
function classifyError(error: unknown): Error {
const message = error instanceof Error ? error.message : String(error)
Expand All @@ -569,9 +575,22 @@ function classifyError(error: unknown): Error {
return new ContextWindowOverflowError(message)
}

if (isNetworkError(error)) {
return new ModelNetworkError(`Language model stream error: ${message}`, { cause: error })
}

return new ModelError(`Language model stream error: ${message}`, { cause: error })
}

/**
* Whether a Vercel provider error is a network failure. In Node, ai-sdk wraps fetch failures as a
* retryable `APICallError` with no status code; in browsers they surface as a raw `TypeError`.
*/
function isNetworkError(error: unknown): boolean {
if (isFetchNetworkError(error)) return true
return APICallError.isInstance(error) && error.isRetryable && error.statusCode === undefined
}

/**
* Maps a single LanguageModelV3 stream part to zero or more Strands ModelStreamEvents.
*/
Expand Down
Loading
Loading