fix(llms): fix abort handling and clean up retry logic

This commit is contained in:
Simon
2026-06-03 22:39:28 +08:00
parent 738a62e96c
commit fb8de08c39
4 changed files with 57 additions and 86 deletions

View File

@@ -25,6 +25,9 @@ export class OpenAIClient implements LLMClient {
abortSignal?: AbortSignal,
options?: InvokeOptions
): Promise<InvokeResult> {
// in case user aborted before invoking
if (abortSignal?.aborted) throw new InvokeError(InvokeErrorTypes.ABORTED, 'Aborted')
// 1. Convert tools to OpenAI format
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
@@ -70,10 +73,11 @@ export class OpenAIClient implements LLMClient {
signal: abortSignal,
})
} catch (error: unknown) {
const isAbortError = (error as any)?.name === 'AbortError'
const errorMessage = isAbortError ? 'Network request aborted' : 'Network request failed'
if (!isAbortError) console.error(error)
throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, errorMessage, error)
if ((error as any)?.name === 'AbortError') {
throw new InvokeError(InvokeErrorTypes.ABORTED, 'Aborted', error)
}
console.error(error)
throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'Network request failed', error)
}
// 3. Handle HTTP errors
@@ -212,11 +216,14 @@ export class OpenAIClient implements LLMClient {
let toolResult: unknown
try {
toolResult = await tool.execute(toolInput)
} catch (e) {
} catch (error: unknown) {
if ((error as any)?.name === 'AbortError') {
throw new InvokeError(InvokeErrorTypes.ABORTED, 'Aborted', error)
}
throw new InvokeError(
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
`Tool execution failed: ${(e as Error).message}`,
e,
`Tool execution failed: ${(error as Error)?.message}`,
error,
data
)
}

View File

@@ -14,6 +14,7 @@ export const InvokeErrorTypes = {
UNKNOWN: 'unknown',
// Non-retryable
ABORTED: 'aborted', // User aborted via AbortSignal — instance has name='AbortError'
CONFIG_ERROR: 'config_error', // Invalid local configuration or hook
AUTH_ERROR: 'auth_error', // Authentication failed
CONTEXT_LENGTH: 'context_length', // Prompt too long
@@ -22,6 +23,16 @@ export const InvokeErrorTypes = {
type InvokeErrorType = (typeof InvokeErrorTypes)[keyof typeof InvokeErrorTypes]
const RETRYABLE_TYPES: readonly InvokeErrorType[] = [
InvokeErrorTypes.NETWORK_ERROR,
InvokeErrorTypes.RATE_LIMIT,
InvokeErrorTypes.SERVER_ERROR,
InvokeErrorTypes.NO_TOOL_CALL,
InvokeErrorTypes.INVALID_TOOL_ARGS,
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
InvokeErrorTypes.UNKNOWN,
]
export class InvokeError extends Error {
type: InvokeErrorType
retryable: boolean
@@ -33,26 +44,12 @@ export class InvokeError extends Error {
constructor(type: InvokeErrorType, message: string, rawError?: unknown, rawResponse?: unknown) {
super(message)
this.name = 'InvokeError'
// ABORTED conforms to the web platform convention so any consumer using
// `err.name === 'AbortError'` (including native DOMException handlers) Just Works.
this.name = type === InvokeErrorTypes.ABORTED ? 'AbortError' : 'InvokeError'
this.type = type
this.retryable = this.isRetryable(type, rawError)
this.retryable = RETRYABLE_TYPES.includes(type)
this.rawError = rawError
this.rawResponse = rawResponse
}
private isRetryable(type: InvokeErrorType, rawError?: unknown): boolean {
const isAbortError = (rawError as any)?.name === 'AbortError'
if (isAbortError) return false
const retryableTypes: InvokeErrorType[] = [
InvokeErrorTypes.NETWORK_ERROR,
InvokeErrorTypes.RATE_LIMIT,
InvokeErrorTypes.SERVER_ERROR,
InvokeErrorTypes.NO_TOOL_CALL,
InvokeErrorTypes.INVALID_TOOL_ARGS,
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
InvokeErrorTypes.UNKNOWN,
]
return retryableTypes.includes(type)
}
}

View File

@@ -50,65 +50,42 @@ export class LLM extends EventTarget {
abortSignal: AbortSignal,
options?: InvokeOptions
): Promise<InvokeResult> {
return await withRetry(
async () => {
// in case user aborted before invoking
if (abortSignal.aborted) throw new Error('AbortError')
const result = await this.client.invoke(messages, tools, abortSignal, options)
return result
return await withRetry(async () => this.client.invoke(messages, tools, abortSignal, options), {
maxRetries: this.config.maxRetries,
onRetry: (attempt, lastError) => {
this.dispatchEvent(
new CustomEvent('retry', {
detail: { attempt, maxAttempts: this.config.maxRetries, lastError },
})
)
},
// retry settings
{
maxRetries: this.config.maxRetries,
onRetry: (attempt: number) => {
this.dispatchEvent(
new CustomEvent('retry', { detail: { attempt, maxAttempts: this.config.maxRetries } })
)
},
onError: (error: Error) => {
this.dispatchEvent(new CustomEvent('error', { detail: { error } }))
},
}
)
})
}
}
/**
* Retry a function until it succeeds or reaches the maximum number of retries.
*/
async function withRetry<T>(
fn: () => Promise<T>,
settings: {
maxRetries: number
onRetry: (attempt: number) => void
onError: (error: Error) => void
onRetry: (attempt: number, lastError: Error) => void
}
): Promise<T> {
let attempt = 0
let lastError: Error | null = null
while (attempt <= settings.maxRetries) {
if (attempt > 0) {
settings.onRetry(attempt)
await new Promise((resolve) => setTimeout(resolve, 100))
}
while (true) {
try {
return await fn()
} catch (error: unknown) {
// do not retry if aborted by user
if ((error as any)?.rawError?.name === 'AbortError') throw error
console.error(error)
settings.onError(error as Error)
// do not retry if error is not retryable (InvokeError)
if (error instanceof InvokeError && !error.retryable) throw error
lastError = error as Error
attempt++
if (attempt > settings.maxRetries) throw error
console.debug('[LLM] retryable failure, will retry:', error)
settings.onRetry(attempt, error as Error)
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
throw lastError!
}