@@ -88,6 +88,12 @@ interface BoundedUpstreamResponse {
8888
8989const DEFAULT_BINARY_MAX_RESPONSE_BYTES = 10 * 1024 * 1024
9090const DEFAULT_JSON_MAX_RESPONSE_BYTES = 2 * 1024 * 1024
91+ const TIMEOUT_ERROR_NAMES = new Set ( [ 'AbortError' , 'BodyTimeoutError' , 'HeadersTimeoutError' , 'TimeoutError' ] )
92+ const DEFAULT_UPSTREAM_TIMEOUT_MS = 10000
93+ const MAX_TRACKED_FAILURES = 512
94+
95+ /** How long a failed upstream fetch is replayed before the upstream is tried again. */
96+ export const UPSTREAM_FAILURE_MAX_AGE = 60
9197
9298export function isSafeHttpsUrl ( url : URL ) : boolean {
9399return url . protocol === 'https:'
@@ -104,6 +110,74 @@ function upstreamError(message: string, statusCode: number, statusMessage: strin
104110} )
105111}
106112
113+ /**
114+ * Transport failures (DNS, reset connection, timeout) carry no status, so they
115+ * would surface as a 500 and read as a defect in the app hosting the proxy.
116+ */
117+ function asUpstreamError ( error : unknown ) : Error {
118+ if ( typeof ( error as { statusCode ?: unknown } | null ) ?. statusCode === 'number' )
119+ return error as Error
120+ const timedOut = isTimeoutError ( error )
121+ return upstreamError (
122+ `Upstream request failed: ${ ( error as Error | null ) ?. message || 'unknown error' } ` ,
123+ timedOut ? 504 : 502 ,
124+ timedOut ? 'Gateway Timeout' : 'Upstream request failed' ,
125+ error ,
126+ )
127+ }
128+
129+ function isTimeoutError ( error : unknown ) : boolean {
130+ const candidate = error as { name ?: string , cause ?: { name ?: string , code ?: string } } | null
131+ const code = candidate ?. cause ?. code
132+ return TIMEOUT_ERROR_NAMES . has ( candidate ?. name || '' )
133+ || TIMEOUT_ERROR_NAMES . has ( candidate ?. cause ?. name || '' )
134+ || ( typeof code === 'string' && code . includes ( 'TIMEOUT' ) )
135+ }
136+
137+ /**
138+ * Short-lived replay of the last failure for a cache key.
139+ *
140+ * Nitro stores nothing when the resolver throws, so an upstream that keeps
141+ * refusing a resource (rate limit, login wall, deleted post) is re-fetched on
142+ * every request. Each attempt raises a server error, and the retries deepen the
143+ * rate limit that caused them.
144+ *
145+ * The gate sits inside the cached resolver, so a replayed failure leaves the
146+ * cache untouched. A resource that was fetched successfully once is still
147+ * served stale by stale-while-revalidate while its upstream is down.
148+ */
149+ function createFailureGate ( maxAge : number ) {
150+ const failures = new Map < string , { until : number , error : { message : string , statusCode : number , statusMessage : string } } > ( )
151+ const failureWindow = Math . min ( UPSTREAM_FAILURE_MAX_AGE , maxAge ) * 1000
152+
153+ return {
154+ replay ( key : string ) : void {
155+ const failure = failures . get ( key )
156+ if ( ! failure )
157+ return
158+ if ( Date . now ( ) >= failure . until ) {
159+ failures . delete ( key )
160+ return
161+ }
162+ throw upstreamError ( failure . error . message , failure . error . statusCode , failure . error . statusMessage )
163+ } ,
164+ record ( key : string , error : unknown ) : void {
165+ const failure = error as Error & { statusCode ?: number , statusMessage ?: string }
166+ // Insertion order is eviction order; the oldest key is the least useful.
167+ if ( failures . size >= MAX_TRACKED_FAILURES )
168+ failures . delete ( failures . keys ( ) . next ( ) . value ! )
169+ failures . set ( key , {
170+ until : Date . now ( ) + failureWindow ,
171+ error : {
172+ message : failure ?. message || 'Upstream request failed' ,
173+ statusCode : failure ?. statusCode ?? 502 ,
174+ statusMessage : failure ?. statusMessage || 'Upstream request failed' ,
175+ } ,
176+ } )
177+ } ,
178+ }
179+ }
180+
107181function resolveMaxResponseBytes ( value : number | undefined , fallback : number ) : number {
108182const maxBytes = value ?? fallback
109183if ( ! Number . isSafeInteger ( maxBytes ) || maxBytes < 0 )
@@ -264,10 +338,13 @@ async function fetchBoundedUpstream(
264338}
265339
266340if ( ! options . ignoreResponseError && response . status >= 400 && response . status < 600 ) {
341+ // An upstream 5xx is the upstream's fault, not ours. Mirroring it would
342+ // report the app hosting this proxy as broken, so it becomes a 502.
343+ const upstreamFault = response . status >= 500
267344await rejectResponse ( response , upstreamError (
268345`Upstream request failed with status ${ response . status } ` ,
269- response . status ,
270- response . statusText || 'Upstream request failed' ,
346+ upstreamFault ? 502 : response . status ,
347+ upstreamFault ? 'Upstream request failed' : ( response . statusText || 'Upstream request failed' ) ,
271348) )
272349}
273350
@@ -298,8 +375,8 @@ async function fetchBoundedUpstream(
298375}
299376}
300377catch ( error ) {
301- primaryError = error
302- throw error
378+ primaryError = asUpstreamError ( error )
379+ throw primaryError
303380}
304381finally {
305382await closePublicNetworkDispatcher ( network , primaryError )
@@ -316,8 +393,34 @@ export function createCachedBinaryFetch(
316393config : CachedBinaryFetchConfig = { } ,
317394) : ( url : string , opts ?: CachedBinaryFetchOptions ) => Promise < CachedBinaryResult > {
318395const maxResponseBytes = resolveMaxResponseBytes ( config . maxResponseBytes , DEFAULT_BINARY_MAX_RESPONSE_BYTES )
396+ const failureGate = createFailureGate ( maxAge )
397+ const cacheKey = ( url : string , opts ?: CachedBinaryFetchOptions ) => {
398+ if ( ! opts )
399+ return hash ( url )
400+ // Vary on headers + redirect mode — callers with different user agents
401+ // or redirect policies may get different upstream responses.
402+ const parts = [ url ]
403+ if ( opts . headers ) {
404+ const entries = Object . entries ( opts . headers ) . sort ( ( [ a ] , [ b ] ) => a . localeCompare ( b ) )
405+ for ( const [ k , v ] of entries )
406+ parts . push ( `${ k } =${ v } ` )
407+ }
408+ if ( opts . redirect )
409+ parts . push ( `redirect=${ opts . redirect } ` )
410+ if ( opts . ignoreResponseError !== undefined )
411+ parts . push ( `ignoreResponseError=${ opts . ignoreResponseError } ` )
412+ return hash ( parts )
413+ }
414+ // The gate replays a failure, and a timeout is one. A caller that allows the
415+ // upstream longer must not inherit a shorter caller's 504, so the gate keys
416+ // on the timeout as well. The cache key stays as it is: a stored response is
417+ // just as valid however long the caller was willing to wait for it.
418+ const gateKey = ( url : string , opts ?: CachedBinaryFetchOptions ) =>
419+ `${ cacheKey ( url , opts ) } :${ opts ?. timeout ?? DEFAULT_UPSTREAM_TIMEOUT_MS } `
319420const cached = defineCachedFunction (
320421async ( url : string , opts ?: CachedBinaryFetchOptions ) : Promise < CachedBinaryResponse & { status : number } > => {
422+ const key = gateKey ( url , opts )
423+ failureGate . replay ( key )
321424const response = await fetchBoundedUpstream ( url , {
322425allowContentType : config . allowContentType ,
323426allowUrl : config . allowUrl ,
@@ -326,7 +429,10 @@ export function createCachedBinaryFetch(
326429maxRedirects : config . maxRedirects ,
327430 maxResponseBytes,
328431redirect : opts ?. redirect ?? ( config . allowUrl ? 'follow' : 'manual' ) ,
329- timeoutMs : opts ?. timeout ?? 10000 ,
432+ timeoutMs : opts ?. timeout ?? DEFAULT_UPSTREAM_TIMEOUT_MS ,
433+ } ) . catch ( ( error ) => {
434+ failureGate . record ( key , error )
435+ throw error
330436} )
331437return {
332438base64 : response . data . byteLength ? Buffer . from ( response . data ) . toString ( 'base64' ) : '' ,
@@ -340,23 +446,7 @@ export function createCachedBinaryFetch(
340446 maxAge,
341447swr : true ,
342448staleMaxAge : maxAge ,
343- getKey : ( url : string , opts ?: CachedBinaryFetchOptions ) => {
344- if ( ! opts )
345- return hash ( url )
346- // Vary on headers + redirect mode — callers with different user agents
347- // or redirect policies may get different upstream responses.
348- const parts = [ url ]
349- if ( opts . headers ) {
350- const entries = Object . entries ( opts . headers ) . sort ( ( [ a ] , [ b ] ) => a . localeCompare ( b ) )
351- for ( const [ k , v ] of entries )
352- parts . push ( `${ k } =${ v } ` )
353- }
354- if ( opts . redirect )
355- parts . push ( `redirect=${ opts . redirect } ` )
356- if ( opts . ignoreResponseError !== undefined )
357- parts . push ( `ignoreResponseError=${ opts . ignoreResponseError } ` )
358- return hash ( parts )
359- } ,
449+ getKey : cacheKey ,
360450} ,
361451)
362452return async ( url , opts ) => {
@@ -381,8 +471,15 @@ export function createCachedJsonFetch<T>(
381471config : CachedJsonFetchConfig < T > ,
382472) : ( url : string , opts ?: { headers ?: Record < string , string > , timeout ?: number } ) => Promise < T > {
383473const maxResponseBytes = resolveMaxResponseBytes ( config . maxResponseBytes , DEFAULT_JSON_MAX_RESPONSE_BYTES )
474+ const failureGate = createFailureGate ( maxAge )
475+ const cacheKey = ( url : string , opts ?: { headers ?: Record < string , string > } ) => hash ( getKey ( url , opts ) )
476+ // See `createCachedBinaryFetch`: the gate keys on the timeout, the cache does not.
477+ const gateKey = ( url : string , opts ?: { headers ?: Record < string , string > , timeout ?: number } ) =>
478+ `${ cacheKey ( url , opts ) } :${ opts ?. timeout ?? DEFAULT_UPSTREAM_TIMEOUT_MS } `
384479return defineCachedFunction (
385480async ( url : string , opts ?: { headers ?: Record < string , string > , timeout ?: number } ) => {
481+ const key = gateKey ( url , opts )
482+ failureGate . replay ( key )
386483const response = await fetchBoundedUpstream ( url , {
387484allowUrl : config . allowUrl ,
388485contentTypePrefixes : config . contentTypePrefixes ,
@@ -391,7 +488,10 @@ export function createCachedJsonFetch<T>(
391488maxRedirects : config . maxRedirects ,
392489 maxResponseBytes,
393490redirect : 'follow' ,
394- timeoutMs : opts ?. timeout ?? 10000 ,
491+ timeoutMs : opts ?. timeout ?? DEFAULT_UPSTREAM_TIMEOUT_MS ,
492+ } ) . catch ( ( error ) => {
493+ failureGate . record ( key , error )
494+ throw error
395495} )
396496const text = new TextDecoder ( ) . decode ( response . data )
397497let data : T
@@ -406,7 +506,13 @@ export function createCachedJsonFetch<T>(
406506throw upstreamError ( 'Upstream response is not valid JSON' , 502 , 'Invalid upstream response' , cause )
407507}
408508}
409- config . validateResponse ?.( data )
509+ try {
510+ config . validateResponse ?.( data )
511+ }
512+ catch ( error ) {
513+ failureGate . record ( key , error )
514+ throw error
515+ }
410516return data
411517} ,
412518{
@@ -415,7 +521,7 @@ export function createCachedJsonFetch<T>(
415521 maxAge,
416522swr : true ,
417523staleMaxAge : maxAge ,
418- getKey : ( url , opts ) => hash ( getKey ( url , opts ) ) ,
524+ getKey : cacheKey ,
419525} ,
420526)
421527}
0 commit comments