@@ -526,6 +526,17 @@ function retryDelayMs({ attempt, response = null, baseMs = RETRY_BACKOFF_BASE_MS
526526 return Math . min ( delay , RETRY_BACKOFF_MAX_MS ) ;
527527}
528528
529+ // --- Mutex Logic with Timeout ---
530+ /**
531+ * The failure carried by a `session.prompt` result, or null when the turn was
532+ * accepted. The SDK resolves (rather than rejects) for a non-2xx backend
533+ * response, so callers must look at `error` as well as at a rejection.
534+ */
535+ function promptErrorOf ( result ) {
536+ if ( ! result || ! result . error ) return null ;
537+ return result . error . info ?. error || result . error ;
538+ }
539+
529540// --- Mutex Logic with Timeout ---
530541async function getImageDataUri ( url ) {
531542 if ( url . startsWith ( 'data:' ) ) {
@@ -1633,6 +1644,7 @@ export function createApp(config) {
16331644 if ( ! entry || entry . sessionId !== sessionId ) return ;
16341645 entry . userTurns = userTurns ;
16351646 entry . pending = false ;
1647+ entry . failed = false ;
16361648 entry . expiresAt = Date . now ( ) + CHAT_SESSION_TTL_MS ;
16371649 chatSessionActive . delete ( sessionId ) ;
16381650 } ;
@@ -1721,7 +1733,12 @@ export function createApp(config) {
17211733 const timeoutPromise = new Promise ( ( _ , reject ) => {
17221734 setTimeout ( ( ) => reject ( new Error ( `Request timeout after ${ timeoutMs } ms` ) ) , timeoutMs ) ;
17231735 } ) ;
1724- return Promise . race ( [ client . session . prompt ( promptParams ) , timeoutPromise ] ) ;
1736+ const result = await Promise . race ( [ client . session . prompt ( promptParams ) , timeoutPromise ] ) ;
1737+ // The SDK resolves for a non-2xx backend response; treat that as the
1738+ // failure it is instead of polling for a reply that will never appear.
1739+ const failure = promptErrorOf ( result ) ;
1740+ if ( failure ) throw failure ;
1741+ return result ;
17251742 }
17261743
17271744 const getCleanupRoots = ( ) => {
@@ -1885,7 +1902,7 @@ export function createApp(config) {
18851902 * @returns {Promise<object> } `{content, reasoning, usage?, error?}`
18861903 */
18871904 async function collectFromEvents ( sessionId , timeoutMs , onDelta , firstDeltaTimeoutMs , idleTimeoutMs , options = { } ) {
1888- const { signal : clientSignal = null , onUsage = null } = options ;
1905+ const { signal : clientSignal = null , onUsage = null , promptFailure = null } = options ;
18891906 const controller = new AbortController ( ) ;
18901907 const eventStreamResult = await client . event . subscribe ( { signal : controller . signal } ) ;
18911908 const eventStream = eventStreamResult . stream ;
@@ -2042,6 +2059,36 @@ export function createApp(config) {
20422059 deltaChars += delta . length ;
20432060 } ;
20442061
2062+ // A prompt call that fails never produces an event, so waiting for one
2063+ // means waiting out the whole window while the caller sees keepalives.
2064+ // The SDK reports a failed turn either by rejecting or by resolving with
2065+ // `{ error }` (a non-2xx from the backend), so both count. A resolved
2066+ // prompt keeps the collector waiting for the events it will produce.
2067+ if ( promptFailure ) {
2068+ const endOnPromptError = ( err ) => {
2069+ if ( finished ) return ;
2070+ finished = true ;
2071+ clearTimeout ( timeoutId ) ;
2072+ if ( firstDeltaTimer ) clearTimeout ( firstDeltaTimer ) ;
2073+ if ( idleTimer ) clearTimeout ( idleTimer ) ;
2074+ releaseClientSignal ( ) ;
2075+ controller . abort ( ) ;
2076+ logDebug ( 'Prompt call failed, ending turn' , {
2077+ sessionId,
2078+ ms : Date . now ( ) - startedAt ,
2079+ error : err ?. message || String ( err )
2080+ } ) ;
2081+ resolve ( { content, reasoning, usage : reportedUsage , __error : err , promptFailed : true } ) ;
2082+ } ;
2083+ promptFailure . then (
2084+ ( result ) => {
2085+ const err = promptErrorOf ( result ) ;
2086+ if ( err ) endOnPromptError ( err ) ;
2087+ } ,
2088+ endOnPromptError
2089+ ) ;
2090+ }
2091+
20452092 ( async ( ) => {
20462093 try {
20472094 for await ( const event of eventStream ) {
@@ -2447,7 +2494,10 @@ export function createApp(config) {
24472494 signal = chatSessionSignalOf ( req , req . body ) ;
24482495 if ( signal ) {
24492496 const entry = chatSessions . get ( signal ) ;
2450- if ( entry && entry . expiresAt > Date . now ( ) && ! entry . pending
2497+ // A turn that ended on a provider error leaves an errored
2498+ // message in the session; resuming it would replay that error
2499+ // on every later turn of the conversation.
2500+ if ( entry && ! entry . failed && entry . expiresAt > Date . now ( ) && ! entry . pending
24512501 && entry . model === `${ pID } /${ mID } `
24522502 && entry . system === ( systemWithGuard || '' )
24532503 && entry . toolPolicy === toolControl . title
@@ -2562,6 +2612,24 @@ export function createApp(config) {
25622612 } ;
25632613 ensureKeepalive ( ) ;
25642614
2615+ // Errors are not model output: send them as their own chunk
2616+ // instead of pushing them through the content filter, which can
2617+ // legitimately hold or strip text.
2618+ const sendStreamError = ( failure ) => {
2619+ if ( res . destroyed ) return ;
2620+ res . write ( `data: ${ JSON . stringify ( {
2621+ id,
2622+ object : 'chat.completion.chunk' ,
2623+ created : Math . floor ( Date . now ( ) / 1000 ) ,
2624+ model : `${ pID } /${ mID } ` ,
2625+ choices : [ {
2626+ index : 0 ,
2627+ delta : { content : `\n\n[Proxy Error] ${ failure ?. name || 'OpenCodeError' } : ${ failure ?. data ?. message || failure ?. message || 'upstream request failed' } ` } ,
2628+ finish_reason : null
2629+ } ]
2630+ } ) } \n\n`) ;
2631+ } ;
2632+
25652633 const sendDelta = ( delta , isReasoning = false ) => {
25662634 if ( ! delta ) return ;
25672635 if ( isReasoning ) rawStreamedReasoning += delta ;
@@ -2649,6 +2717,8 @@ export function createApp(config) {
26492717 await sleep ( retryDelayMs ( { attempt, response : lastAttemptError ?. response } ) ) ;
26502718 }
26512719 try {
2720+ const promptCall = client . session . prompt ( promptParams ) ;
2721+ promptCall . catch ( ( err ) => logDebug ( 'Prompt error:' , err . message ) ) ;
26522722 const collectPromise = collectFromEvents (
26532723 sessionId ,
26542724 REQUEST_TIMEOUT_MS ,
@@ -2657,10 +2727,13 @@ export function createApp(config) {
26572727 EVENT_IDLE_TIMEOUT_MS ,
26582728 {
26592729 signal : clientAbort . signal ,
2660- onUsage : ( usage ) => { reportedUsage = usage ; }
2730+ onUsage : ( usage ) => { reportedUsage = usage ; } ,
2731+ promptFailure : promptCall
26612732 }
26622733 ) ;
26632734 const safeCollect = collectPromise . catch ( ( err ) => ( { __error : err } ) ) ;
2735+ // The collector watches this promise, so a prompt that fails
2736+ // ends the turn instead of waiting out its window.
26642737 client . session . prompt ( promptParams ) . catch ( err => logDebug ( 'Prompt error:' , err . message ) ) ;
26652738 collected = await safeCollect ;
26662739 } catch ( e ) {
@@ -2689,7 +2762,11 @@ export function createApp(config) {
26892762 // Every fallback below re-reads the session through polling.
26902763 // With the caller gone there is nobody to stream to and the
26912764 // turn was aborted, so re-running it would be pure cost.
2692- if ( collected && collected . __error && ! clientGone ) {
2765+ // A prompt that failed leaves nothing to poll for: the turn never
2766+ // started, so the polling fallback would only wait out its window
2767+ // and then report an empty result.
2768+ const noTurnToPoll = Boolean ( collected && collected . promptFailed ) ;
2769+ if ( collected && collected . __error && ! clientGone && ! noTurnToPoll ) {
26932770 logDebug ( 'SSE collect error, falling back to polling' , {
26942771 sessionId,
26952772 error : collected . __error ?. message
@@ -2738,7 +2815,7 @@ export function createApp(config) {
27382815 if ( collected . content ) sendDelta ( collected . content , false ) ;
27392816 }
27402817
2741- if ( ! streamedContent && ! streamedReasoning && ! clientGone ) {
2818+ if ( ! streamedContent && ! streamedReasoning && ! clientGone && ! noTurnToPoll ) {
27422819 logDebug ( 'SSE returned empty, falling back to polling' , { sessionId } ) ;
27432820 const polled = await pollForAssistantResponse ( sessionId , REQUEST_TIMEOUT_MS ) ;
27442821 if ( polled . usage ) reportedUsage = polled . usage ;
@@ -2836,7 +2913,12 @@ export function createApp(config) {
28362913 return ;
28372914 }
28382915
2839- if ( affinitySignal && ! streamToolCallsSeen ) {
2916+ if ( noTurnToPoll ) {
2917+ // The turn never started: report it on the open stream, then
2918+ // drop the mapping so the next request starts a clean session.
2919+ sendStreamError ( collected . __error ) ;
2920+ if ( affinitySignal ) await chatSessionEntryFail ( affinitySignal , sessionId ) ;
2921+ } else if ( affinitySignal && ! streamToolCallsSeen ) {
28402922 chatSessionEntryComplete ( affinitySignal , sessionId , [ ...( reuseUserTurns || [ ] ) , lastUserMsg ] ) ;
28412923 } else if ( affinitySignal ) {
28422924 chatSessionEntryFail ( affinitySignal , sessionId ) ;
@@ -2894,7 +2976,21 @@ export function createApp(config) {
28942976 }
28952977 if ( clientGone ) break ;
28962978 const attemptStart = Date . now ( ) ;
2897- await promptWithTimeout ( promptParams , REQUEST_TIMEOUT_MS ) ;
2979+ // A rejected prompt means the turn never started; polling for
2980+ // it would wait out the whole request timeout first.
2981+ try {
2982+ await promptWithTimeout ( promptParams , REQUEST_TIMEOUT_MS ) ;
2983+ } catch ( promptError ) {
2984+ if ( clientGone ) break ;
2985+ const failure = promptErrorOf ( promptError ) || promptError ;
2986+ error = failure ;
2987+ lastAttemptError = failure ;
2988+ if ( attempt < RETRY_MAX_ATTEMPTS && isTransientUpstreamError ( failure ) ) {
2989+ console . warn ( `[Proxy] Transient upstream error (attempt ${ attempt } /${ RETRY_MAX_ATTEMPTS } ), retrying:` , failure . data ?. message || failure . message || failure . name || 'unknown' ) ;
2990+ continue ;
2991+ }
2992+ break ;
2993+ }
28982994 logDebug ( 'Prompt sent' , { sessionId, ms : Date . now ( ) - attemptStart , attempt } ) ;
28992995 const collected = await pollForAssistantResponse ( sessionId , REQUEST_TIMEOUT_MS ) ;
29002996 content = collected . content || '' ;
@@ -3537,6 +3633,8 @@ export function createApp(config) {
35373633 await sleep ( retryDelayMs ( { attempt, response : lastAttemptError ?. response } ) ) ;
35383634 }
35393635 try {
3636+ const promptCall = client . session . prompt ( promptParams ) ;
3637+ promptCall . catch ( ( err ) => logDebug ( 'Prompt error:' , err . message ) ) ;
35403638 const collectPromise = collectFromEvents (
35413639 sessionId ,
35423640 REQUEST_TIMEOUT_MS ,
@@ -3545,10 +3643,13 @@ export function createApp(config) {
35453643 EVENT_IDLE_TIMEOUT_MS ,
35463644 {
35473645 signal : clientAbort . signal ,
3548- onUsage : ( usage ) => { reportedUsage = usage ; }
3646+ onUsage : ( usage ) => { reportedUsage = usage ; } ,
3647+ promptFailure : promptCall
35493648 }
35503649 ) ;
35513650 const safeCollect = collectPromise . catch ( ( err ) => ( { __error : err } ) ) ;
3651+ // The collector watches this promise, so a prompt that fails
3652+ // ends the turn instead of waiting out its window.
35523653 client . session . prompt ( promptParams ) . catch ( err => logDebug ( 'Prompt error:' , err . message ) ) ;
35533654 collected = await safeCollect ;
35543655 } catch ( e ) {
@@ -3778,7 +3879,21 @@ export function createApp(config) {
37783879 await sleep ( retryDelayMs ( { attempt, response : lastAttemptError ?. response } ) ) ;
37793880 }
37803881 if ( clientGone ) break ;
3781- await promptWithTimeout ( promptParams , REQUEST_TIMEOUT_MS ) ;
3882+ // See the Chat handler: a rejected prompt ends the attempt
3883+ // instead of polling for a reply that cannot appear.
3884+ try {
3885+ await promptWithTimeout ( promptParams , REQUEST_TIMEOUT_MS ) ;
3886+ } catch ( promptError ) {
3887+ if ( clientGone ) break ;
3888+ const failure = promptErrorOf ( promptError ) || promptError ;
3889+ error = failure ;
3890+ lastAttemptError = failure ;
3891+ if ( attempt < RETRY_MAX_ATTEMPTS && isTransientUpstreamError ( failure ) ) {
3892+ console . warn ( `[Proxy] Transient upstream error (attempt ${ attempt } /${ RETRY_MAX_ATTEMPTS } ), retrying:` , failure . data ?. message || failure . message || failure . name || 'unknown' ) ;
3893+ continue ;
3894+ }
3895+ break ;
3896+ }
37823897 const collected = await pollForAssistantResponse ( sessionId , REQUEST_TIMEOUT_MS ) ;
37833898 content = collected . content || '' ;
37843899 reasoning = collected . reasoning || '' ;
@@ -4652,6 +4767,9 @@ export function createApp(config) {
46524767
46534768 let collected = null ;
46544769 try {
4770+ // See the Chat handler: a prompt that fails ends the turn at once.
4771+ const promptCall = client . session . prompt ( promptParams ) ;
4772+ promptCall . catch ( err => logDebug ( 'Responses prompt error:' , err . message ) ) ;
46554773 const collectPromise = collectFromEvents (
46564774 sessionId ,
46574775 REQUEST_TIMEOUT_MS ,
@@ -4660,11 +4778,11 @@ export function createApp(config) {
46604778 EVENT_IDLE_TIMEOUT_MS ,
46614779 {
46624780 signal : clientAbort . signal ,
4663- onUsage : ( usage ) => { reportedUsage = usage ; }
4781+ onUsage : ( usage ) => { reportedUsage = usage ; } ,
4782+ promptFailure : promptCall
46644783 }
46654784 ) ;
46664785 const safeCollect = collectPromise . catch ( ( err ) => ( { __error : err } ) ) ;
4667- client . session . prompt ( promptParams ) . catch ( err => logDebug ( 'Responses prompt error:' , err . message ) ) ;
46684786 collected = await safeCollect ;
46694787 } catch ( e ) {
46704788 collected = { __error : e } ;
0 commit comments