Skip to content

Commit 6a084c7

Browse files
authored
fix(proxy): report upstream failures as gateway errors (#862)
1 parent bf9bd9c commit 6a084c7

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
@@ -88,6 +88,12 @@ interface BoundedUpstreamResponse {
8888

8989
constDEFAULT_BINARY_MAX_RESPONSE_BYTES=10*1024*1024
9090
constDEFAULT_JSON_MAX_RESPONSE_BYTES=2*1024*1024
91+
constTIMEOUT_ERROR_NAMES=newSet(['AbortError','BodyTimeoutError','HeadersTimeoutError','TimeoutError'])
92+
constDEFAULT_UPSTREAM_TIMEOUT_MS=10000
93+
constMAX_TRACKED_FAILURES=512
94+
95+
/** How long a failed upstream fetch is replayed before the upstream is tried again. */
96+
exportconstUPSTREAM_FAILURE_MAX_AGE=60
9197

9298
exportfunctionisSafeHttpsUrl(url: URL): boolean{
9399
returnurl.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+
functionasUpstreamError(error: unknown): Error{
118+
if(typeof(erroras{statusCode?: unknown}|null)?.statusCode==='number')
119+
returnerrorasError
120+
consttimedOut=isTimeoutError(error)
121+
returnupstreamError(
122+
`Upstream request failed: ${(errorasError|null)?.message||'unknown error'}`,
123+
timedOut ? 504 : 502,
124+
timedOut ? 'Gateway Timeout' : 'Upstream request failed',
125+
error,
126+
)
127+
}
128+
129+
functionisTimeoutError(error: unknown): boolean{
130+
constcandidate=erroras{name?: string,cause?: {name?: string,code?: string}}|null
131+
constcode=candidate?.cause?.code
132+
returnTIMEOUT_ERROR_NAMES.has(candidate?.name||'')
133+
||TIMEOUT_ERROR_NAMES.has(candidate?.cause?.name||'')
134+
||(typeofcode==='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+
functioncreateFailureGate(maxAge: number){
150+
constfailures=newMap<string,{until: number,error: {message: string,statusCode: number,statusMessage: string}}>()
151+
constfailureWindow=Math.min(UPSTREAM_FAILURE_MAX_AGE,maxAge)*1000
152+
153+
return{
154+
replay(key: string): void{
155+
constfailure=failures.get(key)
156+
if(!failure)
157+
return
158+
if(Date.now()>=failure.until){
159+
failures.delete(key)
160+
return
161+
}
162+
throwupstreamError(failure.error.message,failure.error.statusCode,failure.error.statusMessage)
163+
},
164+
record(key: string,error: unknown): void{
165+
constfailure=errorasError&{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+
107181
functionresolveMaxResponseBytes(value: number|undefined,fallback: number): number{
108182
constmaxBytes=value??fallback
109183
if(!Number.isSafeInteger(maxBytes)||maxBytes<0)
@@ -264,10 +338,13 @@ async function fetchBoundedUpstream(
264338
}
265339

266340
if(!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+
constupstreamFault=response.status>=500
267344
awaitrejectResponse(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
}
300377
catch(error){
301-
primaryError=error
302-
throwerror
378+
primaryError=asUpstreamError(error)
379+
throwprimaryError
303380
}
304381
finally{
305382
awaitclosePublicNetworkDispatcher(network,primaryError)
@@ -316,8 +393,34 @@ export function createCachedBinaryFetch(
316393
config: CachedBinaryFetchConfig={},
317394
): (url: string,opts?: CachedBinaryFetchOptions)=>Promise<CachedBinaryResult>{
318395
constmaxResponseBytes=resolveMaxResponseBytes(config.maxResponseBytes,DEFAULT_BINARY_MAX_RESPONSE_BYTES)
396+
constfailureGate=createFailureGate(maxAge)
397+
constcacheKey=(url: string,opts?: CachedBinaryFetchOptions)=>{
398+
if(!opts)
399+
returnhash(url)
400+
// Vary on headers + redirect mode — callers with different user agents
401+
// or redirect policies may get different upstream responses.
402+
constparts=[url]
403+
if(opts.headers){
404+
constentries=Object.entries(opts.headers).sort(([a],[b])=>a.localeCompare(b))
405+
for(const[k,v]ofentries)
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+
returnhash(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+
constgateKey=(url: string,opts?: CachedBinaryFetchOptions)=>
419+
`${cacheKey(url,opts)}:${opts?.timeout??DEFAULT_UPSTREAM_TIMEOUT_MS}`
319420
constcached=defineCachedFunction(
320421
async(url: string,opts?: CachedBinaryFetchOptions): Promise<CachedBinaryResponse&{status: number}>=>{
422+
constkey=gateKey(url,opts)
423+
failureGate.replay(key)
321424
constresponse=awaitfetchBoundedUpstream(url,{
322425
allowContentType: config.allowContentType,
323426
allowUrl: config.allowUrl,
@@ -326,7 +429,10 @@ export function createCachedBinaryFetch(
326429
maxRedirects: config.maxRedirects,
327430
maxResponseBytes,
328431
redirect: 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+
throwerror
330436
})
331437
return{
332438
base64: response.data.byteLength ? Buffer.from(response.data).toString('base64') : '',
@@ -340,23 +446,7 @@ export function createCachedBinaryFetch(
340446
maxAge,
341447
swr: true,
342448
staleMaxAge: maxAge,
343-
getKey: (url: string,opts?: CachedBinaryFetchOptions)=>{
344-
if(!opts)
345-
returnhash(url)
346-
// Vary on headers + redirect mode — callers with different user agents
347-
// or redirect policies may get different upstream responses.
348-
constparts=[url]
349-
if(opts.headers){
350-
constentries=Object.entries(opts.headers).sort(([a],[b])=>a.localeCompare(b))
351-
for(const[k,v]ofentries)
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-
returnhash(parts)
359-
},
449+
getKey: cacheKey,
360450
},
361451
)
362452
returnasync(url,opts)=>{
@@ -381,8 +471,15 @@ export function createCachedJsonFetch<T>(
381471
config: CachedJsonFetchConfig<T>,
382472
): (url: string,opts?: {headers?: Record<string,string>,timeout?: number})=>Promise<T>{
383473
constmaxResponseBytes=resolveMaxResponseBytes(config.maxResponseBytes,DEFAULT_JSON_MAX_RESPONSE_BYTES)
474+
constfailureGate=createFailureGate(maxAge)
475+
constcacheKey=(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+
constgateKey=(url: string,opts?: {headers?: Record<string,string>,timeout?: number})=>
478+
`${cacheKey(url,opts)}:${opts?.timeout??DEFAULT_UPSTREAM_TIMEOUT_MS}`
384479
returndefineCachedFunction(
385480
async(url: string,opts?: {headers?: Record<string,string>,timeout?: number})=>{
481+
constkey=gateKey(url,opts)
482+
failureGate.replay(key)
386483
constresponse=awaitfetchBoundedUpstream(url,{
387484
allowUrl: config.allowUrl,
388485
contentTypePrefixes: config.contentTypePrefixes,
@@ -391,7 +488,10 @@ export function createCachedJsonFetch<T>(
391488
maxRedirects: config.maxRedirects,
392489
maxResponseBytes,
393490
redirect: 'follow',
394-
timeoutMs: opts?.timeout??10000,
491+
timeoutMs: opts?.timeout??DEFAULT_UPSTREAM_TIMEOUT_MS,
492+
}).catch((error)=>{
493+
failureGate.record(key,error)
494+
throwerror
395495
})
396496
consttext=newTextDecoder().decode(response.data)
397497
letdata: T
@@ -406,7 +506,13 @@ export function createCachedJsonFetch<T>(
406506
throwupstreamError('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+
throwerror
515+
}
410516
returndata
411517
},
412518
{
@@ -415,7 +521,7 @@ export function createCachedJsonFetch<T>(
415521
maxAge,
416522
swr: true,
417523
staleMaxAge: maxAge,
418-
getKey: (url,opts)=>hash(getKey(url,opts)),
524+
getKey: cacheKey,
419525
},
420526
)
421527
}

0 commit comments

Comments
 (0)