@@ -24,7 +24,6 @@ import { formatCodexProviderForLog } from "../codex/routing";
2424import { resolveEnvValue } from "../config" ;
2525import { signalWithTimeout } from "../lib/abort" ;
2626import { sidecarEnter } from "../lib/sidecar-tracker" ;
27- import { fetchWithResetRetry } from "../lib/upstream-retry" ;
2827import type { OcxConfig , OcxProviderConfig } from "../types" ;
2928import { isProxyAdmissionSecret } from "./auth-cors" ;
3029import { readJsonRequestBody } from "./request-decompress" ;
@@ -36,6 +35,13 @@ export type ImagesEndpoint = "generations" | "edits";
3635/** Image generation is slow (tens of seconds); bound a hung upstream, not a working one. */
3736const IMAGES_UPSTREAM_TIMEOUT_MS = 300_000 ;
3837
38+ /**
39+ * Cap for the buffered upstream response body (100 MiB). Images responses are JSON documents
40+ * containing base64-encoded images — typically a few MB. This prevents an oversized or malicious
41+ * response from exhausting process memory.
42+ */
43+ const IMAGES_RESPONSE_MAX_BYTES = 100 * 1024 * 1024 ;
44+
3945interface NamedProvider {
4046 name : string ;
4147 provider : OcxProviderConfig ;
@@ -168,18 +174,21 @@ export async function handleImages(
168174 const linkedSignal = signalWithTimeout ( timeoutMs , req . signal ) ;
169175 const sidecarExit = sidecarEnter ( "images" ) ;
170176 try {
171- const upstreamResponse = await fetchWithResetRetry (
172- ( ) => fetch ( url , {
173- method : "POST" ,
174- headers ,
175- body : JSON . stringify ( body ) ,
176- signal : linkedSignal . signal ,
177- } ) ,
178- { abortSignal : linkedSignal . signal , label : "images-relay" } ,
179- ) ;
180- // Buffer rather than stream: the payload is one JSON document (base64 image, a few MB),
181- // and buffering keeps the timeout window covering the whole exchange .
177+ // Images POSTs create paid, non-idempotent work. One fetch only: no reset retry without a
178+ // source-proven idempotency contract.
179+ const upstreamResponse = await fetch ( url , {
180+ method : "POST" ,
181+ headers ,
182+ body : JSON . stringify ( body ) ,
183+ signal : linkedSignal . signal ,
184+ } ) ;
185+ // Buffer rather than stream: the payload is one JSON document (base64 image, typically a few
186+ // MB), and buffering keeps the timeout window covering the whole exchange. Cap the size to
187+ // prevent an oversized response from exhausting process memory .
182188 const payload = await upstreamResponse . arrayBuffer ( ) ;
189+ if ( payload . byteLength > IMAGES_RESPONSE_MAX_BYTES ) {
190+ return formatErrorResponse ( 502 , "upstream_error" , `image ${ endpoint } response too large (${ payload . byteLength } bytes)` ) ;
191+ }
183192 recordOutcome ?.( upstreamResponse . status ) ;
184193 const relayHeaders : Record < string , string > = { } ;
185194 const contentType = upstreamResponse . headers . get ( "content-type" ) ;
0 commit comments