2025-10-22 23:31:36 +00:00
import { describe , expect , test } from "bun:test"
2026-04-25 14:59:17 +00:00
import type { NamedError } from "@opencode-ai/core/util/error"
2026-01-26 18:50:04 +00:00
import { APICallError } from "ai"
2026-03-06 02:54:06 +00:00
import { setTimeout as sleep } from "node:timers/promises"
2026-04-30 16:55:33 +00:00
import { Effect , Layer , Schedule } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
2025-10-22 23:31:36 +00:00
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
2026-03-12 13:27:52 +00:00
import { ProviderID } from "../../src/provider/schema"
2026-03-28 16:09:47 +00:00
import { SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
2026-04-30 16:55:33 +00:00
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
2026-03-12 13:27:52 +00:00
const providerID = ProviderID . make ( "test" )
2026-04-30 16:55:33 +00:00
const it = testEffect ( Layer . mergeAll ( SessionStatus . defaultLayer , CrossSpawnSpawner . defaultLayer ) )
2025-10-22 23:31:36 +00:00
function apiError ( headers? : Record < string , string > ) : MessageV2 . APIError {
2026-04-30 16:55:33 +00:00
return MessageV2 . APIError . Schema . parse (
new MessageV2 . APIError ( {
message : "boom" ,
isRetryable : true ,
responseHeaders : headers ,
} ) . toObject ( ) ,
)
2025-10-22 23:31:36 +00:00
}
2026-01-26 21:12:41 +00:00
function wrap ( message : unknown ) : ReturnType < NamedError [ " toObject " ] > {
2026-04-30 16:55:33 +00:00
return { name : "" , data : { message } }
2026-01-26 21:12:41 +00:00
}
2025-11-17 16:30:55 +00:00
describe ( "session.retry.delay" , ( ) = > {
test ( "caps delay at 30 seconds when headers missing" , ( ) = > {
2025-10-22 23:31:36 +00:00
const error = apiError ( )
2025-11-25 20:44:24 +00:00
const delays = Array . from ( { length : 10 } , ( _ , index ) = > SessionRetry . delay ( index + 1 , error ) )
2025-11-17 16:30:55 +00:00
expect ( delays ) . toStrictEqual ( [ 2000 , 4000 , 8000 , 16000 , 30000 , 30000 , 30000 , 30000 , 30000 , 30000 ] )
2025-10-22 23:31:36 +00:00
} )
test ( "prefers retry-after-ms when shorter than exponential" , ( ) = > {
const error = apiError ( { "retry-after-ms" : "1500" } )
2025-11-25 20:44:24 +00:00
expect ( SessionRetry . delay ( 4 , error ) ) . toBe ( 1500 )
2025-10-22 23:31:36 +00:00
} )
test ( "uses retry-after seconds when reasonable" , ( ) = > {
const error = apiError ( { "retry-after" : "30" } )
2025-11-25 20:44:24 +00:00
expect ( SessionRetry . delay ( 3 , error ) ) . toBe ( 30000 )
2025-10-22 23:31:36 +00:00
} )
test ( "accepts http-date retry-after values" , ( ) = > {
const date = new Date ( Date . now ( ) + 20000 ) . toUTCString ( )
const error = apiError ( { "retry-after" : date } )
2025-11-25 20:44:24 +00:00
const d = SessionRetry . delay ( 1 , error )
2025-11-17 16:30:55 +00:00
expect ( d ) . toBeGreaterThanOrEqual ( 19000 )
expect ( d ) . toBeLessThanOrEqual ( 20000 )
2025-10-22 23:31:36 +00:00
} )
test ( "ignores invalid retry hints" , ( ) = > {
const error = apiError ( { "retry-after" : "not-a-number" } )
2025-11-25 20:44:24 +00:00
expect ( SessionRetry . delay ( 1 , error ) ) . toBe ( 2000 )
2025-10-22 23:31:36 +00:00
} )
2025-11-09 17:46:58 +00:00
test ( "ignores malformed date retry hints" , ( ) = > {
const error = apiError ( { "retry-after" : "Invalid Date String" } )
2025-11-25 20:44:24 +00:00
expect ( SessionRetry . delay ( 1 , error ) ) . toBe ( 2000 )
2025-11-09 17:46:58 +00:00
} )
test ( "ignores past date retry hints" , ( ) = > {
const pastDate = new Date ( Date . now ( ) - 5000 ) . toUTCString ( )
const error = apiError ( { "retry-after" : pastDate } )
2025-11-25 20:44:24 +00:00
expect ( SessionRetry . delay ( 1 , error ) ) . toBe ( 2000 )
2025-11-09 17:46:58 +00:00
} )
2025-11-17 16:33:28 +00:00
test ( "uses retry-after values even when exceeding 10 minutes with headers" , ( ) = > {
2025-11-09 17:46:58 +00:00
const error = apiError ( { "retry-after" : "50" } )
2025-11-25 20:44:24 +00:00
expect ( SessionRetry . delay ( 1 , error ) ) . toBe ( 50000 )
2025-11-09 17:46:58 +00:00
const longError = apiError ( { "retry-after-ms" : "700000" } )
2025-11-25 20:44:24 +00:00
expect ( SessionRetry . delay ( 1 , longError ) ) . toBe ( 700000 )
2025-11-09 17:46:58 +00:00
} )
2025-12-31 22:15:49 +00:00
2026-03-28 16:09:47 +00:00
test ( "caps oversized header delays to the runtime timer limit" , ( ) = > {
const error = apiError ( { "retry-after-ms" : "999999999999" } )
expect ( SessionRetry . delay ( 1 , error ) ) . toBe ( SessionRetry . RETRY_MAX_DELAY )
} )
2025-12-31 22:15:49 +00:00
2026-04-30 16:55:33 +00:00
it . live ( "policy updates retry status and increments attempts" , ( ) = >
provideTmpdirInstance ( ( ) = >
Effect . gen ( function * ( ) {
2026-03-28 16:09:47 +00:00
const sessionID = SessionID . make ( "session-retry-test" )
const error = apiError ( { "retry-after-ms" : "0" } )
2026-04-30 16:55:33 +00:00
const status = yield * SessionStatus . Service
const step = yield * Schedule . toStepWithMetadata (
SessionRetry . policy ( {
parse : ( err ) = > MessageV2 . APIError . Schema . parse ( err ) ,
set : ( info ) = >
status . set ( sessionID , {
type : "retry" ,
attempt : info.attempt ,
message : info.message ,
next : info.next ,
2026-03-28 16:09:47 +00:00
} ) ,
} ) ,
)
2026-04-30 16:55:33 +00:00
yield * step ( error )
yield * step ( error )
2026-03-28 16:09:47 +00:00
2026-04-30 16:55:33 +00:00
expect ( yield * status . get ( sessionID ) ) . toMatchObject ( {
2026-03-28 16:09:47 +00:00
type : "retry" ,
attempt : 2 ,
message : "boom" ,
} )
2026-04-30 16:55:33 +00:00
} ) ,
) ,
)
2025-10-22 23:31:36 +00:00
} )
2025-12-24 01:28:41 +00:00
2026-01-26 21:12:41 +00:00
describe ( "session.retry.retryable" , ( ) = > {
test ( "maps too_many_requests json messages" , ( ) = > {
const error = wrap ( JSON . stringify ( { type : "error" , error : { type : "too_many_requests" } } ) )
expect ( SessionRetry . retryable ( error ) ) . toBe ( "Too Many Requests" )
} )
test ( "maps overloaded provider codes" , ( ) = > {
const error = wrap ( JSON . stringify ( { code : "resource_exhausted" } ) )
expect ( SessionRetry . retryable ( error ) ) . toBe ( "Provider is overloaded" )
} )
2026-03-28 16:09:47 +00:00
test ( "does not retry unknown json messages" , ( ) = > {
2026-01-26 21:12:41 +00:00
const error = wrap ( JSON . stringify ( { error : { message : "no_kv_space" } } ) )
2026-03-28 16:09:47 +00:00
expect ( SessionRetry . retryable ( error ) ) . toBeUndefined ( )
2026-01-26 21:12:41 +00:00
} )
2026-01-27 16:22:03 +00:00
test ( "does not throw on numeric error codes" , ( ) = > {
const error = wrap ( JSON . stringify ( { type : "error" , error : { code : 123 } } ) )
const result = SessionRetry . retryable ( error )
expect ( result ) . toBeUndefined ( )
} )
2026-01-26 21:12:41 +00:00
test ( "returns undefined for non-json message" , ( ) = > {
const error = wrap ( "not-json" )
expect ( SessionRetry . retryable ( error ) ) . toBeUndefined ( )
} )
2026-02-11 01:55:22 +00:00
2026-04-07 17:46:01 +00:00
test ( "retries plain text rate limit errors from Alibaba" , ( ) = > {
const msg =
"Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
const error = wrap ( msg )
expect ( SessionRetry . retryable ( error ) ) . toBe ( msg )
} )
test ( "retries plain text rate limit errors" , ( ) = > {
const msg = "Rate limit exceeded, please try again later"
const error = wrap ( msg )
expect ( SessionRetry . retryable ( error ) ) . toBe ( msg )
} )
test ( "retries too many requests in plain text" , ( ) = > {
const msg = "Too many requests, please slow down"
const error = wrap ( msg )
expect ( SessionRetry . retryable ( error ) ) . toBe ( msg )
} )
2026-02-11 01:55:22 +00:00
test ( "does not retry context overflow errors" , ( ) = > {
const error = new MessageV2 . ContextOverflowError ( {
message : "Input exceeds context window of this model" ,
responseBody : '{"error":{"code":"context_length_exceeded"}}' ,
2026-04-30 16:55:33 +00:00
} ) . toObject ( )
2026-02-11 01:55:22 +00:00
expect ( SessionRetry . retryable ( error ) ) . toBeUndefined ( )
} )
2026-03-25 18:08:40 +00:00
2026-04-16 00:58:48 +00:00
test ( "retries 500 errors even when isRetryable is false" , ( ) = > {
2026-04-30 16:55:33 +00:00
const error = MessageV2 . APIError . Schema . parse (
new MessageV2 . APIError ( {
message : "Internal server error" ,
isRetryable : false ,
statusCode : 500 ,
responseBody : '{"type":"api_error","message":"Internal server error"}' ,
} ) . toObject ( ) ,
)
2026-04-16 00:58:48 +00:00
expect ( SessionRetry . retryable ( error ) ) . toBe ( "Internal server error" )
} )
test ( "retries 502 bad gateway errors" , ( ) = > {
2026-04-30 16:55:33 +00:00
const error = MessageV2 . APIError . Schema . parse (
new MessageV2 . APIError ( {
message : "Bad gateway" ,
isRetryable : false ,
statusCode : 502 ,
} ) . toObject ( ) ,
)
2026-04-16 00:58:48 +00:00
expect ( SessionRetry . retryable ( error ) ) . toBe ( "Bad gateway" )
} )
test ( "retries 503 service unavailable errors" , ( ) = > {
2026-04-30 16:55:33 +00:00
const error = MessageV2 . APIError . Schema . parse (
new MessageV2 . APIError ( {
message : "Service unavailable" ,
isRetryable : false ,
statusCode : 503 ,
} ) . toObject ( ) ,
)
2026-04-16 00:58:48 +00:00
expect ( SessionRetry . retryable ( error ) ) . toBe ( "Service unavailable" )
} )
test ( "does not retry 4xx errors when isRetryable is false" , ( ) = > {
2026-04-30 16:55:33 +00:00
const error = MessageV2 . APIError . Schema . parse (
new MessageV2 . APIError ( {
message : "Bad request" ,
isRetryable : false ,
statusCode : 400 ,
} ) . toObject ( ) ,
)
2026-04-16 00:58:48 +00:00
expect ( SessionRetry . retryable ( error ) ) . toBeUndefined ( )
} )
2026-03-25 18:08:40 +00:00
test ( "retries ZlibError decompression failures" , ( ) = > {
2026-04-30 16:55:33 +00:00
const error = MessageV2 . APIError . Schema . parse (
new MessageV2 . APIError ( {
message : "Response decompression failed" ,
isRetryable : true ,
metadata : { code : "ZlibError" } ,
} ) . toObject ( ) ,
)
2026-03-25 18:08:40 +00:00
const retryable = SessionRetry . retryable ( error )
expect ( retryable ) . toBeDefined ( )
expect ( retryable ) . toBe ( "Response decompression failed" )
} )
2026-01-26 21:12:41 +00:00
} )
2025-12-24 01:28:41 +00:00
describe ( "session.message-v2.fromError" , ( ) = > {
test . concurrent (
"converts ECONNRESET socket errors to retryable APIError" ,
async ( ) = > {
using server = Bun . serve ( {
port : 0 ,
idleTimeout : 8 ,
2026-04-16 01:56:23 +00:00
async fetch ( _req ) {
2025-12-24 01:28:41 +00:00
return new Response (
new ReadableStream ( {
async pull ( controller ) {
controller . enqueue ( "Hello," )
2026-03-06 02:54:06 +00:00
await sleep ( 10000 )
2025-12-24 01:28:41 +00:00
controller . enqueue ( " World!" )
controller . close ( )
} ,
} ) ,
{ headers : { "Content-Type" : "text/plain" } } ,
)
} ,
} )
const error = await fetch ( new URL ( "/" , server . url . origin ) )
. then ( ( res ) = > res . text ( ) )
. catch ( ( e ) = > e )
2026-03-12 13:27:52 +00:00
const result = MessageV2 . fromError ( error , { providerID } )
2025-12-24 01:28:41 +00:00
expect ( MessageV2 . APIError . isInstance ( result ) ) . toBe ( true )
2026-04-30 16:55:33 +00:00
if ( ! MessageV2 . APIError . isInstance ( result ) ) throw new Error ( "expected APIError" )
expect ( result . data . isRetryable ) . toBe ( true )
expect ( result . data . message ) . toBe ( "Connection reset by server" )
expect ( result . data . metadata ? . code ) . toBe ( "ECONNRESET" )
expect ( result . data . metadata ? . message ) . toInclude ( "socket connection" )
2025-12-24 01:28:41 +00:00
} ,
15 _000 ,
)
test ( "ECONNRESET socket error is retryable" , ( ) = > {
2026-04-30 16:55:33 +00:00
const error = MessageV2 . APIError . Schema . parse (
new MessageV2 . APIError ( {
message : "Connection reset by server" ,
isRetryable : true ,
metadata : { code : "ECONNRESET" , message : "The socket connection was closed unexpectedly" } ,
} ) . toObject ( ) ,
)
2025-12-24 01:28:41 +00:00
const retryable = SessionRetry . retryable ( error )
expect ( retryable ) . toBeDefined ( )
expect ( retryable ) . toBe ( "Connection reset by server" )
} )
2026-01-26 18:50:04 +00:00
test ( "marks OpenAI 404 status codes as retryable" , ( ) = > {
const error = new APICallError ( {
message : "boom" ,
url : "https://api.openai.com/v1/chat/completions" ,
requestBodyValues : { } ,
statusCode : 404 ,
responseHeaders : { "content-type" : "application/json" } ,
2026-01-26 18:50:58 +00:00
responseBody : '{"error":"boom"}' ,
2026-01-26 18:50:04 +00:00
isRetryable : false ,
} )
2026-04-30 16:55:33 +00:00
const result = MessageV2 . fromError ( error , { providerID : ProviderID.make ( "openai" ) } )
if ( ! MessageV2 . APIError . isInstance ( result ) ) throw new Error ( "expected APIError" )
2026-01-26 18:50:04 +00:00
expect ( result . data . isRetryable ) . toBe ( true )
} )
2026-04-23 21:31:43 +00:00
test ( "converts OpenAI server_error stream chunks to retryable APIError" , ( ) = > {
const result = MessageV2 . fromError (
{
message : JSON.stringify ( {
type : "error" ,
sequence_number : 2 ,
error : {
type : "server_error" ,
code : "server_error" ,
message : "An error occurred while processing your request." ,
param : null ,
} ,
} ) ,
} ,
{ providerID : ProviderID.make ( "openai" ) } ,
)
expect ( MessageV2 . APIError . isInstance ( result ) ) . toBe ( true )
2026-04-30 16:55:33 +00:00
if ( ! MessageV2 . APIError . isInstance ( result ) ) throw new Error ( "expected APIError" )
expect ( result . data . isRetryable ) . toBe ( true )
2026-04-23 21:31:43 +00:00
expect ( SessionRetry . retryable ( result ) ) . toBe ( "An error occurred while processing your request." )
} )
2025-12-24 01:28:41 +00:00
} )