@@ -95,6 +95,12 @@ interface BoundedUpstreamResponse {
9595
9696const DEFAULT_BINARY_MAX_RESPONSE_BYTES = 10 * 1024 * 1024
9797const DEFAULT_JSON_MAX_RESPONSE_BYTES = 2 * 1024 * 1024
98+ const TIMEOUT_ERROR_NAMES = new Set ( [ 'AbortError' , 'BodyTimeoutError' , 'HeadersTimeoutError' , 'TimeoutError' ] )
99+ const DEFAULT_UPSTREAM_TIMEOUT_MS = 10000
100+ const MAX_TRACKED_FAILURES = 512
101+
102+ /** How long a failed upstream fetch is replayed before the upstream is tried again. */
103+ export const UPSTREAM_FAILURE_MAX_AGE = 60
98104
99105export function isSafeHttpsUrl ( url : URL ) : boolean {
100106return url . protocol === 'https:'
@@ -111,6 +117,74 @@ function upstreamError(message: string, statusCode: number, statusMessage: strin
111117} )
112118}
113119
120+ /**
121+ * Transport failures (DNS, reset connection, timeout) carry no status, so they
122+ * would surface as a 500 and read as a defect in the app hosting the proxy.
123+ */
124+ function asUpstreamError ( error : unknown ) : Error {
125+ if ( typeof ( error as { statusCode ?: unknown } | null ) ?. statusCode === 'number' )
126+ return error as Error
127+ const timedOut = isTimeoutError ( error )
128+ return upstreamError (
129+ `Upstream request failed: ${ ( error as Error | null ) ?. message || 'unknown error' } ` ,
130+ timedOut ? 504 : 502 ,
131+ timedOut ? 'Gateway Timeout' : 'Upstream request failed' ,
132+ error ,
133+ )
134+ }
135+
136+ function isTimeoutError ( error : unknown ) : boolean {
137+ const candidate = error as { name ?: string , cause ?: { name ?: string , code ?: string } } | null
138+ const code = candidate ?. cause ?. code
139+ return TIMEOUT_ERROR_NAMES . has ( candidate ?. name || '' )
140+ || TIMEOUT_ERROR_NAMES . has ( candidate ?. cause ?. name || '' )
141+ || ( typeof code === 'string' && code . includes ( 'TIMEOUT' ) )
142+ }
143+
144+ /**
145+ * Short-lived replay of the last failure for a cache key.
146+ *
147+ * Nitro stores nothing when the resolver throws, so an upstream that keeps
148+ * refusing a resource (rate limit, login wall, deleted post) is re-fetched on
149+ * every request. Each attempt raises a server error, and the retries deepen the
150+ * rate limit that caused them.
151+ *
152+ * The gate sits inside the cached resolver, so a replayed failure leaves the
153+ * cache untouched. A resource that was fetched successfully once is still
154+ * served stale by stale-while-revalidate while its upstream is down.
155+ */
156+ function createFailureGate ( maxAge : number ) {
157+ const failures = new Map < string , { until : number , error : { message : string , statusCode : number , statusMessage : string } } > ( )
158+ const failureWindow = Math . min ( UPSTREAM_FAILURE_MAX_AGE , maxAge ) * 1000
159+
160+ return {
161+ replay ( key : string ) : void {
162+ const failure = failures . get ( key )
163+ if ( ! failure )
164+ return
165+ if ( Date . now ( ) >= failure . until ) {
166+ failures . delete ( key )
167+ return
168+ }
169+ throw upstreamError ( failure . error . message , failure . error . statusCode , failure . error . statusMessage )
170+ } ,
171+ record ( key : string , error : unknown ) : void {
172+ const failure = error as Error & { statusCode ?: number , statusMessage ?: string }
173+ // Insertion order is eviction order; the oldest key is the least useful.
174+ if ( failures . size >= MAX_TRACKED_FAILURES )
175+ failures . delete ( failures . keys ( ) . next ( ) . value ! )
176+ failures . set ( key , {
177+ until : Date . now ( ) + failureWindow ,
178+ error : {
179+ message : failure ?. message || 'Upstream request failed' ,
180+ statusCode : failure ?. statusCode ?? 502 ,
181+ statusMessage : failure ?. statusMessage || 'Upstream request failed' ,
182+ } ,
183+ } )
184+ } ,
185+ }
186+ }
187+
114188function resolveMaxResponseBytes ( value : number | undefined , fallback : number ) : number {
115189const maxBytes = value ?? fallback
116190if ( ! Number . isSafeInteger ( maxBytes ) || maxBytes < 0 )
@@ -271,10 +345,13 @@ async function fetchBoundedUpstream(
271345}
272346
273347if ( ! options . ignoreResponseError && response . status >= 400 && response . status < 600 ) {
348+ // An upstream 5xx is the upstream's fault, not ours. Mirroring it would
349+ // report the app hosting this proxy as broken, so it becomes a 502.
350+ const upstreamFault = response . status >= 500
274351await rejectResponse ( response , upstreamError (
275352`Upstream request failed with status ${ response . status } ` ,
276- response . status ,
277- response . statusText || 'Upstream request failed' ,
353+ upstreamFault ? 502 : response . status ,
354+ upstreamFault ? 'Upstream request failed' : ( response . statusText || 'Upstream request failed' ) ,
278355) )
279356}
280357
@@ -305,8 +382,8 @@ async function fetchBoundedUpstream(
305382}
306383}
307384catch ( error ) {
308- primaryError = error
309- throw error
385+ primaryError = asUpstreamError ( error )
386+ throw primaryError
310387}
311388finally {
312389await closePublicNetworkDispatcher ( network , primaryError )
@@ -323,8 +400,34 @@ export function createCachedBinaryFetch(
323400config : CachedBinaryFetchConfig = { } ,
324401) : ( url : string , opts ?: CachedBinaryFetchOptions ) => Promise < CachedBinaryResult > {
325402const maxResponseBytes = resolveMaxResponseBytes ( config . maxResponseBytes , DEFAULT_BINARY_MAX_RESPONSE_BYTES )
403+ const failureGate = createFailureGate ( maxAge )
404+ const cacheKey = ( url : string , opts ?: CachedBinaryFetchOptions ) => {
405+ if ( ! opts )
406+ return hash ( url )
407+ // Vary on headers + redirect mode — callers with different user agents
408+ // or redirect policies may get different upstream responses.
409+ const parts = [ url ]
410+ if ( opts . headers ) {
411+ const entries = Object . entries ( opts . headers ) . sort ( ( [ a ] , [ b ] ) => a . localeCompare ( b ) )
412+ for ( const [ k , v ] of entries )
413+ parts . push ( `${ k } =${ v } ` )
414+ }
415+ if ( opts . redirect )
416+ parts . push ( `redirect=${ opts . redirect } ` )
417+ if ( opts . ignoreResponseError !== undefined )
418+ parts . push ( `ignoreResponseError=${ opts . ignoreResponseError } ` )
419+ return hash ( parts )
420+ }
421+ // The gate replays a failure, and a timeout is one. A caller that allows the
422+ // upstream longer must not inherit a shorter caller's 504, so the gate keys
423+ // on the timeout as well. The cache key stays as it is: a stored response is
424+ // just as valid however long the caller was willing to wait for it.
425+ const gateKey = ( url : string , opts ?: CachedBinaryFetchOptions ) =>
426+ `${ cacheKey ( url , opts ) } :${ opts ?. timeout ?? DEFAULT_UPSTREAM_TIMEOUT_MS } `
326427const cached = defineCachedFunction (
327428async ( url : string , opts ?: CachedBinaryFetchOptions ) : Promise < CachedBinaryResponse & { status : number } > => {
429+ const key = gateKey ( url , opts )
430+ failureGate . replay ( key )
328431const response = await fetchBoundedUpstream ( url , {
329432allowContentType : config . allowContentType ,
330433allowUrl : config . allowUrl ,
@@ -333,7 +436,10 @@ export function createCachedBinaryFetch(
333436maxRedirects : config . maxRedirects ,
334437 maxResponseBytes,
335438redirect : opts ?. redirect ?? ( config . allowUrl ? 'follow' : 'manual' ) ,
336- timeoutMs : opts ?. timeout ?? 10000 ,
439+ timeoutMs : opts ?. timeout ?? DEFAULT_UPSTREAM_TIMEOUT_MS ,
440+ } ) . catch ( ( error ) => {
441+ failureGate . record ( key , error )
442+ throw error
337443} )
338444return {
339445base64 : response . data . byteLength ? Buffer . from ( response . data ) . toString ( 'base64' ) : '' ,
@@ -347,23 +453,7 @@ export function createCachedBinaryFetch(
347453 maxAge,
348454swr : true ,
349455staleMaxAge : maxAge ,
350- getKey : ( url : string , opts ?: CachedBinaryFetchOptions ) => {
351- if ( ! opts )
352- return hash ( url )
353- // Vary on headers + redirect mode — callers with different user agents
354- // or redirect policies may get different upstream responses.
355- const parts = [ url ]
356- if ( opts . headers ) {
357- const entries = Object . entries ( opts . headers ) . sort ( ( [ a ] , [ b ] ) => a . localeCompare ( b ) )
358- for ( const [ k , v ] of entries )
359- parts . push ( `${ k } =${ v } ` )
360- }
361- if ( opts . redirect )
362- parts . push ( `redirect=${ opts . redirect } ` )
363- if ( opts . ignoreResponseError !== undefined )
364- parts . push ( `ignoreResponseError=${ opts . ignoreResponseError } ` )
365- return hash ( parts )
366- } ,
456+ getKey : cacheKey ,
367457} ,
368458)
369459return async ( url , opts ) => {
@@ -388,8 +478,15 @@ export function createCachedJsonFetch<T>(
388478config : CachedJsonFetchConfig < T > ,
389479) : ( url : string , opts ?: { headers ?: Record < string , string > , timeout ?: number } ) => Promise < T > {
390480const maxResponseBytes = resolveMaxResponseBytes ( config . maxResponseBytes , DEFAULT_JSON_MAX_RESPONSE_BYTES )
481+ const failureGate = createFailureGate ( maxAge )
482+ const cacheKey = ( url : string , opts ?: { headers ?: Record < string , string > } ) => hash ( getKey ( url , opts ) )
483+ // See `createCachedBinaryFetch`: the gate keys on the timeout, the cache does not.
484+ const gateKey = ( url : string , opts ?: { headers ?: Record < string , string > , timeout ?: number } ) =>
485+ `${ cacheKey ( url , opts ) } :${ opts ?. timeout ?? DEFAULT_UPSTREAM_TIMEOUT_MS } `
391486return defineCachedFunction (
392487async ( url : string , opts ?: { headers ?: Record < string , string > , timeout ?: number } ) => {
488+ const key = gateKey ( url , opts )
489+ failureGate . replay ( key )
393490const response = await fetchBoundedUpstream ( url , {
394491allowUrl : config . allowUrl ,
395492contentTypePrefixes : config . contentTypePrefixes ,
@@ -398,7 +495,10 @@ export function createCachedJsonFetch<T>(
398495maxRedirects : config . maxRedirects ,
399496 maxResponseBytes,
400497redirect : 'follow' ,
401- timeoutMs : opts ?. timeout ?? 10000 ,
498+ timeoutMs : opts ?. timeout ?? DEFAULT_UPSTREAM_TIMEOUT_MS ,
499+ } ) . catch ( ( error ) => {
500+ failureGate . record ( key , error )
501+ throw error
402502} )
403503const text = new TextDecoder ( ) . decode ( response . data )
404504let data : T
@@ -413,7 +513,13 @@ export function createCachedJsonFetch<T>(
413513throw upstreamError ( 'Upstream response is not valid JSON' , 502 , 'Invalid upstream response' , cause )
414514}
415515}
416- config . validateResponse ?.( data )
516+ try {
517+ config . validateResponse ?.( data )
518+ }
519+ catch ( error ) {
520+ failureGate . record ( key , error )
521+ throw error
522+ }
417523return data
418524} ,
419525{
@@ -422,7 +528,7 @@ export function createCachedJsonFetch<T>(
422528 maxAge,
423529swr : true ,
424530staleMaxAge : maxAge ,
425- getKey : ( url , opts ) => hash ( getKey ( url , opts ) ) ,
531+ getKey : cacheKey ,
426532} ,
427533)
428534}
0 commit comments