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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# ---------------------------------------------------------------
# To update the sha:
# https://github.com/github/gh-base-image/pkgs/container/gh-base-image%2Fgh-base-noble
FROM ghcr.io/github/gh-base-image/gh-base-noble:20260527-203230-gabf2049e0@sha256:e6d4192acbdf566584c77f1306cd72cac3a381be6ff6174c85ee21bb164a8b46 AS base
FROM ghcr.io/github/gh-base-image/gh-base-noble:20260603-101723-g62a660e20@sha256:216e9ea02bb1f000a3e9ed1beeb9127b33a7661ae61f041879e61fffd52e1bd2 AS base

# Install curl for Node install and determining the early access branch
# Install git for cloning docs-early-access & translations repos
Expand Down
4 changes: 2 additions & 2 deletions src/ai-tools/lib/spaces-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Copilot Space API response types
*/
import { fetchWithRetry } from '@/frame/lib/fetch-utils'
import { fetchWithRetry, readBodyWithTimeout } from '@/frame/lib/fetch-utils'

export interface SpaceResource {
id: number
Expand Down Expand Up @@ -85,7 +85,7 @@ export async function fetchCopilotSpace(spaceUrl: string): Promise<SpaceData> {
}
}

return (await response.json()) as SpaceData
return (await readBodyWithTimeout(response, () => response.json(), 30_000)) as SpaceData
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fetchWithRetry } from '@/frame/lib/fetch-utils'
import { fetchWithRetry, readBodyWithTimeout } from '@/frame/lib/fetch-utils'
import type { Response, NextFunction } from 'express'

import patterns from '@/frame/lib/patterns'
Expand Down Expand Up @@ -100,7 +100,7 @@ export default async function archivedEnterpriseVersionsAssets(
},
)

const body = await r.arrayBuffer()
const body = await readBodyWithTimeout(r, () => r.arrayBuffer(), 8_000)

res.set('accept-ranges', 'bytes')
const contentType = r.headers.get('content-type')
Expand Down
6 changes: 3 additions & 3 deletions src/archives/middleware/archived-enterprise-versions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Response, NextFunction } from 'express'
import { fetchWithRetry } from '@/frame/lib/fetch-utils'
import { fetchWithRetry, readBodyWithTimeout } from '@/frame/lib/fetch-utils'

import statsd, { adaptForTimer } from '@/observability/lib/statsd'
import { createLogger } from '@/observability/logger'
Expand Down Expand Up @@ -275,7 +275,7 @@ export default async function archivedEnterpriseVersions(
if (r.status !== 200) {
let upstreamBody: string | undefined
try {
upstreamBody = await r.text()
upstreamBody = await readBodyWithTimeout(r, () => r.text(), timeoutConfiguration.response)
} catch {
// ignore — body reading failure shouldn't affect error handling
}
Expand All @@ -301,7 +301,7 @@ export default async function archivedEnterpriseVersions(
}

if (r.status === 200) {
const body = await r.text()
const body = await readBodyWithTimeout(r, () => r.text(), timeoutConfiguration.response)
const [, withoutLanguagePath] = splitByLanguage(req.path)
const isDeveloperPage = withoutLanguagePath?.startsWith(
`/enterprise/${requestedVersion}/developer`,
Expand Down
139 changes: 116 additions & 23 deletions src/frame/lib/fetch-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ export interface FetchWithRetryOptions {
retryDelay?: number
timeout?: number
throwHttpErrors?: boolean
/**
* How the `timeout` is enforced:
* - 'full' (default): bounds the entire request, including body transfer.
* The abort signal stays armed through body reads, so pair this with
* `readBodyWithTimeout` to report body-phase timeouts consistently.
* - 'ttfb': bounds only time-to-first-byte. The timer is cleared once the
* response resolves, leaving body reads unbounded. Use for large, trusted,
* well-cached payloads (e.g. multi-MB archived `redirects.json`) where a
* short deadline should fail fast on an unresponsive server but must not
* abort a legitimately long download.
*/
timeoutMode?: 'full' | 'ttfb'
// Note: Custom HTTPS agents are not supported in native fetch
// Consider using undici or node-fetch if custom agent support is critical
}
Expand All @@ -30,45 +42,120 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

function getHost(url: string | URL): string {
try {
return new URL(typeof url === 'string' ? url : url.toString()).host
} catch {
return 'unknown'
}
}

/**
* Fetch with timeout support
* Fetch with timeout support.
*
* `timeoutMode` controls what the deadline bounds:
*
* - 'full' (default): enforced with `AbortSignal.timeout()`, whose signal
* stays armed after this function returns. Callers typically read the body
* (`r.json()`, `r.arrayBuffer()`) after the response resolves; because the
* signal is never cleared, that body read is aborted by the same deadline.
* So the timeout bounds the full request (time-to-first-byte AND body
* transfer). Use `readBodyWithTimeout` to consume the body so a body-phase
* timeout reports the same way as a TTFB timeout.
*
* - 'ttfb': enforced with a manual `AbortController` whose timer is cleared as
* soon as the response resolves. Only time-to-first-byte is bounded; the
* body read that follows is left unbounded. Use for large, trusted,
* well-cached payloads where a short deadline should fail fast on an
* unresponsive server but not abort a legitimately long download.
*/
async function fetchWithTimeout(
url: string | URL,
init?: RequestInit,
timeout?: number,
timeoutMode: 'full' | 'ttfb' = 'full',
): Promise<Response> {
if (!timeout) {
return fetch(url, init)
}

const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
if (timeoutMode === 'ttfb') {
// Abort if headers don't arrive in time, but clear the timer once the
// response resolves so the subsequent body read isn't bounded by the same
// deadline.
const controller = new AbortController()
const signal = init?.signal
? AbortSignal.any([init.signal, controller.signal])
: controller.signal
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
controller.abort()
}, timeout)

try {
return await fetch(url, { ...init, signal })
} catch (error) {
// Only our own timer firing counts as a timeout; a caller-provided
// signal aborting is left untouched so it isn't misreported.
if (timedOut) {
statsd.increment(STATSD_FETCH_TIMEOUT, 1, [`host:${getHost(url)}`])
throw new Error(`Request timed out after ${timeout}ms`)
}
throw error
} finally {
clearTimeout(timer)
}
}

const timeoutSignal = AbortSignal.timeout(timeout)
// Honor a caller-provided signal too, rather than overwriting it.
const signal = init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal

try {
const response = await fetch(url, {
...init,
signal: controller.signal,
})
clearTimeout(timeoutId)
return response
return await fetch(url, { ...init, signal })
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error && error.name === 'AbortError') {
const host = (() => {
try {
return new URL(typeof url === 'string' ? url : url.toString()).host
} catch {
return 'unknown'
}
})()
statsd.increment(STATSD_FETCH_TIMEOUT, 1, [`host:${host}`])
// `AbortSignal.timeout()` aborts with a `TimeoutError`; a caller-provided
// signal aborts with its own reason (e.g. `AbortError`), which we leave
// untouched so caller cancellations aren't misreported as timeouts.
if (error instanceof Error && error.name === 'TimeoutError') {
statsd.increment(STATSD_FETCH_TIMEOUT, 1, [`host:${getHost(url)}`])
throw new Error(`Request timed out after ${timeout}ms`)
}
throw error
}
}

/**
* Read a response body, reporting a timeout the same way `fetchWithTimeout`
* does for time-to-first-byte.
*
* The body read is already bounded by the deadline set on the originating
* `fetchWithRetry`/`fetchWithTimeout` call (the abort signal stays armed
* through body transfer). This wrapper just translates the resulting
* `TimeoutError` into the friendly "Request timed out" error and emits the
* `fetch.timeout` metric, so body-phase timeouts are observable.
*
* @param response The response whose body to read (used for the metric host).
* @param read A callback that performs the body read, e.g. `() => r.json()`.
* @param timeout The deadline that bounded the request, for the error message.
*/
export async function readBodyWithTimeout<T>(
response: Response,
read: () => Promise<T>,
timeout?: number,
): Promise<T> {
try {
return await read()
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
statsd.increment(STATSD_FETCH_TIMEOUT, 1, [`host:${getHost(response.url)}`])
throw new Error(timeout ? `Request timed out after ${timeout}ms` : 'Request timed out')
}
throw error
}
}

/**
* Fetch with retry logic matching got's behavior
*/
Expand All @@ -77,13 +164,13 @@ export async function fetchWithRetry(
init?: RequestInit,
options: FetchWithRetryOptions = {},
): Promise<Response> {
const { retries = 0, timeout, throwHttpErrors = true } = options
const { retries = 0, timeout, throwHttpErrors = true, timeoutMode = 'full' } = options

let lastError: Error | null = null

for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await fetchWithTimeout(url, init, timeout)
const response = await fetchWithTimeout(url, init, timeout, timeoutMode)

// Check if we should retry based on status code
if (response.status >= 500 && attempt < retries) {
Expand Down Expand Up @@ -128,15 +215,21 @@ export async function fetchWithRetry(
/**
* Create a streaming fetch request that returns a ReadableStream
* This replaces got.stream functionality
*
* Defaults to `timeoutMode: 'ttfb'` because streaming callers consume the body
* incrementally over a `reader.read()` loop that can legitimately run far longer
* than the connect deadline. A `'full'` default would keep `AbortSignal.timeout()`
* armed through that loop and abort a valid long answer mid-stream. Callers that
* want the deadline to bound the whole transfer can pass `timeoutMode: 'full'`.
*/
export async function fetchStream(
url: string | URL,
init?: RequestInit,
options: FetchWithRetryOptions = {},
): Promise<Response> {
const { timeout, throwHttpErrors = true } = options
const { timeout, throwHttpErrors = true, timeoutMode = 'ttfb' } = options

const response = await fetchWithTimeout(url, init, timeout)
const response = await fetchWithTimeout(url, init, timeout, timeoutMode)

// Check for HTTP errors if throwHttpErrors is enabled
if (throwHttpErrors && !response.ok && response.status >= 400) {
Expand Down
6 changes: 6 additions & 0 deletions src/frame/lib/get-remote-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ export default async function getRemoteJSON(
retries,
timeout,
throwHttpErrors: true,
// The `redirects.json` files are large (5-10MB) but well-cached, and
// the configured timeout is deliberately a short time-to-first-byte
// budget (got's `timeout.response` semantics). Bound only TTFB so a
// slow server fails fast without aborting a legitimately long body
// download mid-transfer.
timeoutMode: 'ttfb',
},
)

Expand Down
Loading
Loading