Skip to content

Commit d4d3099

Browse files
authored
fix(proxy): report upstream failures as gateway errors (1.x backport) (#863)
1 parent 394e05c commit d4d3099

5 files changed

Lines changed: 690 additions & 26 deletions

File tree

‎packages/script/src/runtime/server/utils/cached-upstream.ts‎

Lines changed: 131 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ interface BoundedUpstreamResponse {
9595

9696
constDEFAULT_BINARY_MAX_RESPONSE_BYTES=10*1024*1024
9797
constDEFAULT_JSON_MAX_RESPONSE_BYTES=2*1024*1024
98+
constTIMEOUT_ERROR_NAMES=newSet(['AbortError','BodyTimeoutError','HeadersTimeoutError','TimeoutError'])
99+
constDEFAULT_UPSTREAM_TIMEOUT_MS=10000
100+
constMAX_TRACKED_FAILURES=512
101+
102+
/** How long a failed upstream fetch is replayed before the upstream is tried again. */
103+
exportconstUPSTREAM_FAILURE_MAX_AGE=60
98104

99105
exportfunctionisSafeHttpsUrl(url: URL): boolean{
100106
returnurl.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+
functionasUpstreamError(error: unknown): Error{
125+
if(typeof(erroras{statusCode?: unknown}|null)?.statusCode==='number')
126+
returnerrorasError
127+
consttimedOut=isTimeoutError(error)
128+
returnupstreamError(
129+
`Upstream request failed: ${(errorasError|null)?.message||'unknown error'}`,
130+
timedOut ? 504 : 502,
131+
timedOut ? 'Gateway Timeout' : 'Upstream request failed',
132+
error,
133+
)
134+
}
135+
136+
functionisTimeoutError(error: unknown): boolean{
137+
constcandidate=erroras{name?: string,cause?: {name?: string,code?: string}}|null
138+
constcode=candidate?.cause?.code
139+
returnTIMEOUT_ERROR_NAMES.has(candidate?.name||'')
140+
||TIMEOUT_ERROR_NAMES.has(candidate?.cause?.name||'')
141+
||(typeofcode==='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+
functioncreateFailureGate(maxAge: number){
157+
constfailures=newMap<string,{until: number,error: {message: string,statusCode: number,statusMessage: string}}>()
158+
constfailureWindow=Math.min(UPSTREAM_FAILURE_MAX_AGE,maxAge)*1000
159+
160+
return{
161+
replay(key: string): void{
162+
constfailure=failures.get(key)
163+
if(!failure)
164+
return
165+
if(Date.now()>=failure.until){
166+
failures.delete(key)
167+
return
168+
}
169+
throwupstreamError(failure.error.message,failure.error.statusCode,failure.error.statusMessage)
170+
},
171+
record(key: string,error: unknown): void{
172+
constfailure=errorasError&{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+
114188
functionresolveMaxResponseBytes(value: number|undefined,fallback: number): number{
115189
constmaxBytes=value??fallback
116190
if(!Number.isSafeInteger(maxBytes)||maxBytes<0)
@@ -271,10 +345,13 @@ async function fetchBoundedUpstream(
271345
}
272346

273347
if(!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+
constupstreamFault=response.status>=500
274351
awaitrejectResponse(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
}
307384
catch(error){
308-
primaryError=error
309-
throwerror
385+
primaryError=asUpstreamError(error)
386+
throwprimaryError
310387
}
311388
finally{
312389
awaitclosePublicNetworkDispatcher(network,primaryError)
@@ -323,8 +400,34 @@ export function createCachedBinaryFetch(
323400
config: CachedBinaryFetchConfig={},
324401
): (url: string,opts?: CachedBinaryFetchOptions)=>Promise<CachedBinaryResult>{
325402
constmaxResponseBytes=resolveMaxResponseBytes(config.maxResponseBytes,DEFAULT_BINARY_MAX_RESPONSE_BYTES)
403+
constfailureGate=createFailureGate(maxAge)
404+
constcacheKey=(url: string,opts?: CachedBinaryFetchOptions)=>{
405+
if(!opts)
406+
returnhash(url)
407+
// Vary on headers + redirect mode — callers with different user agents
408+
// or redirect policies may get different upstream responses.
409+
constparts=[url]
410+
if(opts.headers){
411+
constentries=Object.entries(opts.headers).sort(([a],[b])=>a.localeCompare(b))
412+
for(const[k,v]ofentries)
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+
returnhash(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+
constgateKey=(url: string,opts?: CachedBinaryFetchOptions)=>
426+
`${cacheKey(url,opts)}:${opts?.timeout??DEFAULT_UPSTREAM_TIMEOUT_MS}`
326427
constcached=defineCachedFunction(
327428
async(url: string,opts?: CachedBinaryFetchOptions): Promise<CachedBinaryResponse&{status: number}>=>{
429+
constkey=gateKey(url,opts)
430+
failureGate.replay(key)
328431
constresponse=awaitfetchBoundedUpstream(url,{
329432
allowContentType: config.allowContentType,
330433
allowUrl: config.allowUrl,
@@ -333,7 +436,10 @@ export function createCachedBinaryFetch(
333436
maxRedirects: config.maxRedirects,
334437
maxResponseBytes,
335438
redirect: 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+
throwerror
337443
})
338444
return{
339445
base64: response.data.byteLength ? Buffer.from(response.data).toString('base64') : '',
@@ -347,23 +453,7 @@ export function createCachedBinaryFetch(
347453
maxAge,
348454
swr: true,
349455
staleMaxAge: maxAge,
350-
getKey: (url: string,opts?: CachedBinaryFetchOptions)=>{
351-
if(!opts)
352-
returnhash(url)
353-
// Vary on headers + redirect mode — callers with different user agents
354-
// or redirect policies may get different upstream responses.
355-
constparts=[url]
356-
if(opts.headers){
357-
constentries=Object.entries(opts.headers).sort(([a],[b])=>a.localeCompare(b))
358-
for(const[k,v]ofentries)
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-
returnhash(parts)
366-
},
456+
getKey: cacheKey,
367457
},
368458
)
369459
returnasync(url,opts)=>{
@@ -388,8 +478,15 @@ export function createCachedJsonFetch<T>(
388478
config: CachedJsonFetchConfig<T>,
389479
): (url: string,opts?: {headers?: Record<string,string>,timeout?: number})=>Promise<T>{
390480
constmaxResponseBytes=resolveMaxResponseBytes(config.maxResponseBytes,DEFAULT_JSON_MAX_RESPONSE_BYTES)
481+
constfailureGate=createFailureGate(maxAge)
482+
constcacheKey=(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+
constgateKey=(url: string,opts?: {headers?: Record<string,string>,timeout?: number})=>
485+
`${cacheKey(url,opts)}:${opts?.timeout??DEFAULT_UPSTREAM_TIMEOUT_MS}`
391486
returndefineCachedFunction(
392487
async(url: string,opts?: {headers?: Record<string,string>,timeout?: number})=>{
488+
constkey=gateKey(url,opts)
489+
failureGate.replay(key)
393490
constresponse=awaitfetchBoundedUpstream(url,{
394491
allowUrl: config.allowUrl,
395492
contentTypePrefixes: config.contentTypePrefixes,
@@ -398,7 +495,10 @@ export function createCachedJsonFetch<T>(
398495
maxRedirects: config.maxRedirects,
399496
maxResponseBytes,
400497
redirect: 'follow',
401-
timeoutMs: opts?.timeout??10000,
498+
timeoutMs: opts?.timeout??DEFAULT_UPSTREAM_TIMEOUT_MS,
499+
}).catch((error)=>{
500+
failureGate.record(key,error)
501+
throwerror
402502
})
403503
consttext=newTextDecoder().decode(response.data)
404504
letdata: T
@@ -413,7 +513,13 @@ export function createCachedJsonFetch<T>(
413513
throwupstreamError('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+
throwerror
522+
}
417523
returndata
418524
},
419525
{
@@ -422,7 +528,7 @@ export function createCachedJsonFetch<T>(
422528
maxAge,
423529
swr: true,
424530
staleMaxAge: maxAge,
425-
getKey: (url,opts)=>hash(getKey(url,opts)),
531+
getKey: cacheKey,
426532
},
427533
)
428534
}

0 commit comments

Comments
 (0)