Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 131 additions & 25 deletions packages/script/src/runtime/server/utils/cached-upstream.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,12 @@ interface BoundedUpstreamResponse {

const DEFAULT_BINARY_MAX_RESPONSE_BYTES = 10 * 1024 * 1024
const DEFAULT_JSON_MAX_RESPONSE_BYTES = 2 * 1024 * 1024
const TIMEOUT_ERROR_NAMES = new Set(['AbortError', 'BodyTimeoutError', 'HeadersTimeoutError', 'TimeoutError'])
const DEFAULT_UPSTREAM_TIMEOUT_MS = 10000
const MAX_TRACKED_FAILURES = 512

/** How long a failed upstream fetch is replayed before the upstream is tried again. */
export const UPSTREAM_FAILURE_MAX_AGE = 60

export function isSafeHttpsUrl(url: URL): boolean {
return url.protocol === 'https:'
Expand All@@ -104,6 +110,74 @@ function upstreamError(message: string, statusCode: number, statusMessage: strin
})
}

/**
* Transport failures (DNS, reset connection, timeout) carry no status, so they
* would surface as a 500 and read as a defect in the app hosting the proxy.
*/
function asUpstreamError(error: unknown): Error {
if (typeof (error as { statusCode?: unknown } | null)?.statusCode === 'number')
return error as Error
const timedOut = isTimeoutError(error)
return upstreamError(
`Upstream request failed: ${(error as Error | null)?.message || 'unknown error'}`,
timedOut ? 504 : 502,
timedOut ? 'Gateway Timeout' : 'Upstream request failed',
error,
)
}

function isTimeoutError(error: unknown): boolean {
const candidate = error as { name?: string, cause?: { name?: string, code?: string } } | null
const code = candidate?.cause?.code
return TIMEOUT_ERROR_NAMES.has(candidate?.name || '')
|| TIMEOUT_ERROR_NAMES.has(candidate?.cause?.name || '')
|| (typeof code === 'string' && code.includes('TIMEOUT'))
}

/**
* Short-lived replay of the last failure for a cache key.
*
* Nitro stores nothing when the resolver throws, so an upstream that keeps
* refusing a resource (rate limit, login wall, deleted post) is re-fetched on
* every request. Each attempt raises a server error, and the retries deepen the
* rate limit that caused them.
*
* The gate sits inside the cached resolver, so a replayed failure leaves the
* cache untouched. A resource that was fetched successfully once is still
* served stale by stale-while-revalidate while its upstream is down.
*/
function createFailureGate(maxAge: number) {
const failures = new Map<string, { until: number, error: { message: string, statusCode: number, statusMessage: string } }>()
const failureWindow = Math.min(UPSTREAM_FAILURE_MAX_AGE, maxAge) * 1000

return {
replay(key: string): void {
const failure = failures.get(key)
if (!failure)
return
if (Date.now() >= failure.until) {
failures.delete(key)
return
}
throw upstreamError(failure.error.message, failure.error.statusCode, failure.error.statusMessage)
},
record(key: string, error: unknown): void {
const failure = error as Error & { statusCode?: number, statusMessage?: string }
// Insertion order is eviction order; the oldest key is the least useful.
if (failures.size >= MAX_TRACKED_FAILURES)
failures.delete(failures.keys().next().value!)
failures.set(key, {
until: Date.now() + failureWindow,
error: {
message: failure?.message || 'Upstream request failed',
statusCode: failure?.statusCode ?? 502,
statusMessage: failure?.statusMessage || 'Upstream request failed',
},
})
},
}
}

function resolveMaxResponseBytes(value: number | undefined, fallback: number): number {
const maxBytes = value ?? fallback
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
Expand DownExpand Up@@ -264,10 +338,13 @@ async function fetchBoundedUpstream(
}

if (!options.ignoreResponseError && response.status >= 400 && response.status < 600) {
// An upstream 5xx is the upstream's fault, not ours. Mirroring it would
// report the app hosting this proxy as broken, so it becomes a 502.
const upstreamFault = response.status >= 500
await rejectResponse(response, upstreamError(
`Upstream request failed with status ${response.status}`,
response.status,
response.statusText || 'Upstream request failed',
upstreamFault ? 502 : response.status,
upstreamFault ? 'Upstream request failed' : (response.statusText || 'Upstream request failed'),
))
}

Expand DownExpand Up@@ -298,8 +375,8 @@ async function fetchBoundedUpstream(
}
}
catch (error) {
primaryError = error
throw error
primaryError = asUpstreamError(error)
throw primaryError
}
finally {
await closePublicNetworkDispatcher(network, primaryError)
Expand All@@ -316,8 +393,34 @@ export function createCachedBinaryFetch(
config: CachedBinaryFetchConfig = {},
): (url: string, opts?: CachedBinaryFetchOptions) => Promise<CachedBinaryResult> {
const maxResponseBytes = resolveMaxResponseBytes(config.maxResponseBytes, DEFAULT_BINARY_MAX_RESPONSE_BYTES)
const failureGate = createFailureGate(maxAge)
const cacheKey = (url: string, opts?: CachedBinaryFetchOptions) => {
if (!opts)
return hash(url)
// Vary on headers + redirect mode β€” callers with different user agents
// or redirect policies may get different upstream responses.
const parts = [url]
if (opts.headers) {
const entries = Object.entries(opts.headers).sort(([a], [b]) => a.localeCompare(b))
for (const [k, v] of entries)
parts.push(`${k}=${v}`)
}
if (opts.redirect)
parts.push(`redirect=${opts.redirect}`)
if (opts.ignoreResponseError !== undefined)
parts.push(`ignoreResponseError=${opts.ignoreResponseError}`)
return hash(parts)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// The gate replays a failure, and a timeout is one. A caller that allows the
// upstream longer must not inherit a shorter caller's 504, so the gate keys
// on the timeout as well. The cache key stays as it is: a stored response is
// just as valid however long the caller was willing to wait for it.
const gateKey = (url: string, opts?: CachedBinaryFetchOptions) =>
`${cacheKey(url, opts)}:${opts?.timeout ?? DEFAULT_UPSTREAM_TIMEOUT_MS}`
const cached = defineCachedFunction(
async (url: string, opts?: CachedBinaryFetchOptions): Promise<CachedBinaryResponse & { status: number }> => {
const key = gateKey(url, opts)
failureGate.replay(key)
const response = await fetchBoundedUpstream(url, {
allowContentType: config.allowContentType,
allowUrl: config.allowUrl,
Expand All@@ -326,7 +429,10 @@ export function createCachedBinaryFetch(
maxRedirects: config.maxRedirects,
maxResponseBytes,
redirect: opts?.redirect ?? (config.allowUrl ? 'follow' : 'manual'),
timeoutMs: opts?.timeout ?? 10000,
timeoutMs: opts?.timeout ?? DEFAULT_UPSTREAM_TIMEOUT_MS,
}).catch((error) => {
failureGate.record(key, error)
throw error
})
return {
base64: response.data.byteLength ? Buffer.from(response.data).toString('base64') : '',
Expand All@@ -340,23 +446,7 @@ export function createCachedBinaryFetch(
maxAge,
swr: true,
staleMaxAge: maxAge,
getKey: (url: string, opts?: CachedBinaryFetchOptions) => {
if (!opts)
return hash(url)
// Vary on headers + redirect mode β€” callers with different user agents
// or redirect policies may get different upstream responses.
const parts = [url]
if (opts.headers) {
const entries = Object.entries(opts.headers).sort(([a], [b]) => a.localeCompare(b))
for (const [k, v] of entries)
parts.push(`${k}=${v}`)
}
if (opts.redirect)
parts.push(`redirect=${opts.redirect}`)
if (opts.ignoreResponseError !== undefined)
parts.push(`ignoreResponseError=${opts.ignoreResponseError}`)
return hash(parts)
},
getKey: cacheKey,
},
)
return async (url, opts) => {
Expand All@@ -381,8 +471,15 @@ export function createCachedJsonFetch<T>(
config: CachedJsonFetchConfig<T>,
): (url: string, opts?: { headers?: Record<string, string>, timeout?: number }) => Promise<T> {
const maxResponseBytes = resolveMaxResponseBytes(config.maxResponseBytes, DEFAULT_JSON_MAX_RESPONSE_BYTES)
const failureGate = createFailureGate(maxAge)
const cacheKey = (url: string, opts?: { headers?: Record<string, string> }) => hash(getKey(url, opts))
// See `createCachedBinaryFetch`: the gate keys on the timeout, the cache does not.
const gateKey = (url: string, opts?: { headers?: Record<string, string>, timeout?: number }) =>
`${cacheKey(url, opts)}:${opts?.timeout ?? DEFAULT_UPSTREAM_TIMEOUT_MS}`
return defineCachedFunction(
async (url: string, opts?: { headers?: Record<string, string>, timeout?: number }) => {
const key = gateKey(url, opts)
failureGate.replay(key)
const response = await fetchBoundedUpstream(url, {
allowUrl: config.allowUrl,
contentTypePrefixes: config.contentTypePrefixes,
Expand All@@ -391,7 +488,10 @@ export function createCachedJsonFetch<T>(
maxRedirects: config.maxRedirects,
maxResponseBytes,
redirect: 'follow',
timeoutMs: opts?.timeout ?? 10000,
timeoutMs: opts?.timeout ?? DEFAULT_UPSTREAM_TIMEOUT_MS,
}).catch((error) => {
failureGate.record(key, error)
throw error
})
const text = new TextDecoder().decode(response.data)
let data: T
Expand All@@ -406,7 +506,13 @@ export function createCachedJsonFetch<T>(
throw upstreamError('Upstream response is not valid JSON', 502, 'Invalid upstream response', cause)
}
}
config.validateResponse?.(data)
try {
config.validateResponse?.(data)
}
catch (error) {
failureGate.record(key, error)
throw error
}
return data
},
{
Expand All@@ -415,7 +521,7 @@ export function createCachedJsonFetch<T>(
maxAge,
swr: true,
staleMaxAge: maxAge,
getKey: (url, opts) => hash(getKey(url, opts)),
getKey: cacheKey,
},
)
}
Loading
Loading