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
8 changes: 6 additions & 2 deletions src/campaign/external-optimizer-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,15 +179,19 @@ async function handleCallback<TResponse>(
let result: TResponse
try {
result = await args.evaluate({ candidate, exampleId: body.exampleId }, signal)
} catch {
} catch (error) {
observe({
kind: 'refusal',
reason: 'evaluation-failed',
candidate,
candidateHash,
exampleId: body.exampleId,
})
sendJsonIfOpen(response, 500, { error: 'evaluation failed' })
// The thrown detail is the only diagnostic for a failed evaluation —
// an opaque 500 costs the caller a blind debugging round.
sendJsonIfOpen(response, 500, {
error: `evaluation failed: ${String(error).slice(0, 400)}`,
})
return
}
let encoded: string
Expand Down
5 changes: 4 additions & 1 deletion src/llm-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,10 @@ function providerTokenCount(value: unknown): number | undefined {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
}

const RETRYABLE_STATUS = new Set([429, 502, 503, 504])
// 522/524: Cloudflare edge timeouts (origin connect/respond) — transient
// exactly like 504. Router deployments serve through Cloudflare, so a long
// non-streaming call can draw these from the edge, not the origin.
const RETRYABLE_STATUS = new Set([429, 502, 503, 504, 522, 524])

/**
* Transient transport/network error signatures, matched against an error's
Expand Down
16 changes: 16 additions & 0 deletions tests/campaign/external-optimizer-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,22 @@ describe('external optimizer server lifecycle', () => {
expect(activeEvaluations).toBe(0)
})

it('carries the thrown detail in the evaluation-failed 500 body', async () => {
const callback = await startExternalOptimizerCallback({
token: 'secret',
maxEvaluations: 1,
evaluate: async () => {
throw new Error('router 503 after 3 attempts')
},
})
const response = await postEvaluation(callback.url, callback.token)
const body = (await response.json()) as { error: string }
await callback.close()

expect(response.status).toBe(500)
expect(body.error).toContain('router 503 after 3 attempts')
})

it('closes the callback and aborts active evaluation work with the owner signal', async () => {
const owner = new AbortController()
const started = deferred<void>()
Expand Down
23 changes: 23 additions & 0 deletions tests/llm-transient-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { isTransientLlmError, LlmCallError } from '../src/llm-client'

describe('isTransientLlmError — HTTP status classification', () => {
const call = (status: number) => new LlmCallError('upstream error', status, '', 'test-model')

it.each([429, 502, 503, 504])('retries gateway-transient status %i', (status) => {
expect(isTransientLlmError(call(status))).toBe(true)
})

it.each([522, 524])('retries Cloudflare edge timeout %i like a 504', (status) => {
expect(isTransientLlmError(call(status))).toBe(true)
})

it.each([400, 401, 404, 422])('never retries deterministic status %i', (status) => {
expect(isTransientLlmError(call(status))).toBe(false)
})

it('reads a retryable numeric status off a foreign (non-LlmCallError) error', () => {
const err = Object.assign(new Error('edge timeout'), { status: 524 })
expect(isTransientLlmError(err)).toBe(true)
})
})
Loading