@@ -11,6 +11,18 @@ export interface FetchWithRetryOptions {
1111 retryDelay ?: number
1212 timeout ?: number
1313 throwHttpErrors ?: boolean
14+ /**
15+ * How the `timeout` is enforced:
16+ * - 'full' (default): bounds the entire request, including body transfer.
17+ * The abort signal stays armed through body reads, so pair this with
18+ * `readBodyWithTimeout` to report body-phase timeouts consistently.
19+ * - 'ttfb': bounds only time-to-first-byte. The timer is cleared once the
20+ * response resolves, leaving body reads unbounded. Use for large, trusted,
21+ * well-cached payloads (e.g. multi-MB archived `redirects.json`) where a
22+ * short deadline should fail fast on an unresponsive server but must not
23+ * abort a legitimately long download.
24+ */
25+ timeoutMode ?: 'full' | 'ttfb'
1426 // Note: Custom HTTPS agents are not supported in native fetch
1527 // Consider using undici or node-fetch if custom agent support is critical
1628}
@@ -30,45 +42,120 @@ function sleep(ms: number): Promise<void> {
3042 return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) )
3143}
3244
45+ function getHost ( url : string | URL ) : string {
46+ try {
47+ return new URL ( typeof url === 'string' ? url : url . toString ( ) ) . host
48+ } catch {
49+ return 'unknown'
50+ }
51+ }
52+
3353/**
34- * Fetch with timeout support
54+ * Fetch with timeout support.
55+ *
56+ * `timeoutMode` controls what the deadline bounds:
57+ *
58+ * - 'full' (default): enforced with `AbortSignal.timeout()`, whose signal
59+ * stays armed after this function returns. Callers typically read the body
60+ * (`r.json()`, `r.arrayBuffer()`) after the response resolves; because the
61+ * signal is never cleared, that body read is aborted by the same deadline.
62+ * So the timeout bounds the full request (time-to-first-byte AND body
63+ * transfer). Use `readBodyWithTimeout` to consume the body so a body-phase
64+ * timeout reports the same way as a TTFB timeout.
65+ *
66+ * - 'ttfb': enforced with a manual `AbortController` whose timer is cleared as
67+ * soon as the response resolves. Only time-to-first-byte is bounded; the
68+ * body read that follows is left unbounded. Use for large, trusted,
69+ * well-cached payloads where a short deadline should fail fast on an
70+ * unresponsive server but not abort a legitimately long download.
3571 */
3672async function fetchWithTimeout (
3773 url : string | URL ,
3874 init ?: RequestInit ,
3975 timeout ?: number ,
76+ timeoutMode : 'full' | 'ttfb' = 'full' ,
4077) : Promise < Response > {
4178 if ( ! timeout ) {
4279 return fetch ( url , init )
4380 }
4481
45- const controller = new AbortController ( )
46- const timeoutId = setTimeout ( ( ) => controller . abort ( ) , timeout )
82+ if ( timeoutMode === 'ttfb' ) {
83+ // Abort if headers don't arrive in time, but clear the timer once the
84+ // response resolves so the subsequent body read isn't bounded by the same
85+ // deadline.
86+ const controller = new AbortController ( )
87+ const signal = init ?. signal
88+ ? AbortSignal . any ( [ init . signal , controller . signal ] )
89+ : controller . signal
90+ let timedOut = false
91+ const timer = setTimeout ( ( ) => {
92+ timedOut = true
93+ controller . abort ( )
94+ } , timeout )
95+
96+ try {
97+ return await fetch ( url , { ...init , signal } )
98+ } catch ( error ) {
99+ // Only our own timer firing counts as a timeout; a caller-provided
100+ // signal aborting is left untouched so it isn't misreported.
101+ if ( timedOut ) {
102+ statsd . increment ( STATSD_FETCH_TIMEOUT , 1 , [ `host:${ getHost ( url ) } ` ] )
103+ throw new Error ( `Request timed out after ${ timeout } ms` )
104+ }
105+ throw error
106+ } finally {
107+ clearTimeout ( timer )
108+ }
109+ }
110+
111+ const timeoutSignal = AbortSignal . timeout ( timeout )
112+ // Honor a caller-provided signal too, rather than overwriting it.
113+ const signal = init ?. signal ? AbortSignal . any ( [ init . signal , timeoutSignal ] ) : timeoutSignal
47114
48115 try {
49- const response = await fetch ( url , {
50- ...init ,
51- signal : controller . signal ,
52- } )
53- clearTimeout ( timeoutId )
54- return response
116+ return await fetch ( url , { ...init , signal } )
55117 } catch ( error ) {
56- clearTimeout ( timeoutId )
57- if ( error instanceof Error && error . name === 'AbortError' ) {
58- const host = ( ( ) => {
59- try {
60- return new URL ( typeof url === 'string' ? url : url . toString ( ) ) . host
61- } catch {
62- return 'unknown'
63- }
64- } ) ( )
65- statsd . increment ( STATSD_FETCH_TIMEOUT , 1 , [ `host:${ host } ` ] )
118+ // `AbortSignal.timeout()` aborts with a `TimeoutError`; a caller-provided
119+ // signal aborts with its own reason (e.g. `AbortError`), which we leave
120+ // untouched so caller cancellations aren't misreported as timeouts.
121+ if ( error instanceof Error && error . name === 'TimeoutError' ) {
122+ statsd . increment ( STATSD_FETCH_TIMEOUT , 1 , [ `host:${ getHost ( url ) } ` ] )
66123 throw new Error ( `Request timed out after ${ timeout } ms` )
67124 }
68125 throw error
69126 }
70127}
71128
129+ /**
130+ * Read a response body, reporting a timeout the same way `fetchWithTimeout`
131+ * does for time-to-first-byte.
132+ *
133+ * The body read is already bounded by the deadline set on the originating
134+ * `fetchWithRetry`/`fetchWithTimeout` call (the abort signal stays armed
135+ * through body transfer). This wrapper just translates the resulting
136+ * `TimeoutError` into the friendly "Request timed out" error and emits the
137+ * `fetch.timeout` metric, so body-phase timeouts are observable.
138+ *
139+ * @param response The response whose body to read (used for the metric host).
140+ * @param read A callback that performs the body read, e.g. `() => r.json()`.
141+ * @param timeout The deadline that bounded the request, for the error message.
142+ */
143+ export async function readBodyWithTimeout < T > (
144+ response : Response ,
145+ read : ( ) => Promise < T > ,
146+ timeout ?: number ,
147+ ) : Promise < T > {
148+ try {
149+ return await read ( )
150+ } catch ( error ) {
151+ if ( error instanceof Error && error . name === 'TimeoutError' ) {
152+ statsd . increment ( STATSD_FETCH_TIMEOUT , 1 , [ `host:${ getHost ( response . url ) } ` ] )
153+ throw new Error ( timeout ? `Request timed out after ${ timeout } ms` : 'Request timed out' )
154+ }
155+ throw error
156+ }
157+ }
158+
72159/**
73160 * Fetch with retry logic matching got's behavior
74161 */
@@ -77,13 +164,13 @@ export async function fetchWithRetry(
77164 init ?: RequestInit ,
78165 options : FetchWithRetryOptions = { } ,
79166) : Promise < Response > {
80- const { retries = 0 , timeout, throwHttpErrors = true } = options
167+ const { retries = 0 , timeout, throwHttpErrors = true , timeoutMode = 'full' } = options
81168
82169 let lastError : Error | null = null
83170
84171 for ( let attempt = 0 ; attempt <= retries ; attempt ++ ) {
85172 try {
86- const response = await fetchWithTimeout ( url , init , timeout )
173+ const response = await fetchWithTimeout ( url , init , timeout , timeoutMode )
87174
88175 // Check if we should retry based on status code
89176 if ( response . status >= 500 && attempt < retries ) {
@@ -128,15 +215,21 @@ export async function fetchWithRetry(
128215/**
129216 * Create a streaming fetch request that returns a ReadableStream
130217 * This replaces got.stream functionality
218+ *
219+ * Defaults to `timeoutMode: 'ttfb'` because streaming callers consume the body
220+ * incrementally over a `reader.read()` loop that can legitimately run far longer
221+ * than the connect deadline. A `'full'` default would keep `AbortSignal.timeout()`
222+ * armed through that loop and abort a valid long answer mid-stream. Callers that
223+ * want the deadline to bound the whole transfer can pass `timeoutMode: 'full'`.
131224 */
132225export async function fetchStream (
133226 url : string | URL ,
134227 init ?: RequestInit ,
135228 options : FetchWithRetryOptions = { } ,
136229) : Promise < Response > {
137- const { timeout, throwHttpErrors = true } = options
230+ const { timeout, throwHttpErrors = true , timeoutMode = 'ttfb' } = options
138231
139- const response = await fetchWithTimeout ( url , init , timeout )
232+ const response = await fetchWithTimeout ( url , init , timeout , timeoutMode )
140233
141234 // Check for HTTP errors if throwHttpErrors is enabled
142235 if ( throwHttpErrors && ! response . ok && response . status >= 400 ) {
0 commit comments