@@ -7,12 +7,11 @@ import { maskToken } from "../utils/token.ts";
77import { sourceConfig , trackingHeaders } from "./headers.ts" ;
88import { Dispatcher , getGlobalDispatcher } from "undici" ;
99
10- // 请求已由 --timeout 对应的 signal 控制超时。这里在实际发送前关闭底层默认的
11- // 300 秒响应头超时,避免用户设置更长等待时间后,请求仍在 300 秒时被提前中断。
12- // 只覆盖 headersTimeout,响应体的空闲超时保持原值。
13- const withoutHeadersTimeout : Dispatcher . DispatcherComposeInterceptor =
10+ // Core owns the request deadline and body idle window. Override transport
11+ // limits only for this request, preserving the current proxy/connection pool.
12+ const withoutTransportTimeouts : Dispatcher . DispatcherComposeInterceptor =
1413 ( dispatch ) => ( options , handler ) =>
15- dispatch ( { ...options , headersTimeout : 0 } , handler ) ;
14+ dispatch ( { ...options , headersTimeout : 0 , bodyTimeout : 0 } , handler ) ;
1615
1716/** 传输层依赖:UA 用 identity,timeout/verbose 用 settings。凭证由调用方(Client)注头。 */
1817export interface HttpDeps {
@@ -42,14 +41,19 @@ function bodyReferencesOssUrl(body: unknown): boolean {
4241 return JSON . stringify ( body ) . includes ( "oss://" ) ;
4342}
4443
44+ /** Successful raw response bodies must be consumed or explicitly cancelled by the caller. */
4545export async function request ( deps : HttpDeps , opts : RequestOpts ) : Promise < Response > {
4646 const timeoutMs = ( opts . timeout ?? deps . settings . timeout ) * 1000 ;
4747 const requestSignal = createRequestSignal ( timeoutMs , opts . signal ) ;
4848 try {
49- // Raw/streaming callers retain ownership of successful response bodies.
50- return await performRequest ( deps , opts , requestSignal . signal ) ;
51- } finally {
49+ const response = await performRequest ( deps , opts , requestSignal . signal ) ;
50+ // After headers, raw/streaming responses use a rolling idle window rather
51+ // than a total deadline. The body owns cleanup until EOF/cancel/error.
52+ return protectResponseBody ( response , requestSignal ) ;
53+ } catch ( error ) {
5254 requestSignal . cleanup ( ) ;
55+ if ( requestSignal . signal . aborted ) throw requestSignal . signal . reason ;
56+ throw error ;
5357 }
5458}
5559
@@ -94,12 +98,16 @@ async function performRequest(
9498 : JSON . stringify ( opts . body )
9599 : undefined ,
96100 signal,
97- // 复用当前客户端,仅关闭本次请求的响应头超时。
98- // 从原型调用 compose,兼容没有 compose 方法的客户端;类型断言适配 Node 的 fetch 类型。
99- dispatcher : Dispatcher . prototype . compose . call (
100- getGlobalDispatcher ( ) ,
101- withoutHeadersTimeout ,
102- ) as unknown as RequestInit [ "dispatcher" ] ,
101+ // Bun's native fetch ignores dispatchers and its undici shim has no compose.
102+ // Keep native fetch in both runtimes, adapting only transport controls.
103+ ...( process . versions . bun
104+ ? { timeout : false as const }
105+ : {
106+ dispatcher : Dispatcher . prototype . compose . call (
107+ getGlobalDispatcher ( ) ,
108+ withoutTransportTimeouts ,
109+ ) as unknown as RequestInit [ "dispatcher" ] ,
110+ } ) ,
103111 } ;
104112 const res = await fetch ( opts . url , requestInit ) ;
105113
@@ -125,24 +133,127 @@ async function performRequest(
125133 return res ;
126134}
127135
128- function createRequestSignal (
129- timeoutMs : number ,
130- parentSignal ?: AbortSignal ,
131- ) : { signal : AbortSignal ; cleanup : ( ) => void } {
136+ interface RequestSignal {
137+ signal : AbortSignal ;
138+ cleanup : ( ) => void ;
139+ abort : ( reason ?: unknown ) => void ;
140+ resetIdleTimeout : ( ) => void ;
141+ pauseTimeout : ( ) => void ;
142+ }
143+
144+ function createRequestSignal ( timeoutMs : number , parentSignal ?: AbortSignal ) : RequestSignal {
132145 const controller = new AbortController ( ) ;
133- const timeout = setTimeout ( ( ) => controller . abort ( ) , timeoutMs ) ;
146+ let timeout : ReturnType < typeof setTimeout > | undefined ;
147+ let finished = false ;
148+ const armTimer = ( reason ?: unknown ) => {
149+ if ( finished ) return ;
150+ clearTimeout ( timeout ) ;
151+ timeout = setTimeout ( ( ) => controller . abort ( reason ) , timeoutMs ) ;
152+ } ;
134153 const abortFromParent = ( ) => controller . abort ( parentSignal ?. reason ) ;
135154 const cleanup = ( ) => {
155+ finished = true ;
136156 clearTimeout ( timeout ) ;
137157 parentSignal ?. removeEventListener ( "abort" , abortFromParent ) ;
138158 controller . signal . removeEventListener ( "abort" , cleanup ) ;
139159 } ;
140160
141161 controller . signal . addEventListener ( "abort" , cleanup , { once : true } ) ;
142162 if ( parentSignal ?. aborted ) abortFromParent ( ) ;
143- else parentSignal ?. addEventListener ( "abort" , abortFromParent , { once : true } ) ;
163+ else {
164+ parentSignal ?. addEventListener ( "abort" , abortFromParent , { once : true } ) ;
165+ armTimer ( ) ;
166+ }
167+
168+ return {
169+ signal : controller . signal ,
170+ cleanup,
171+ abort : ( reason ) => controller . abort ( reason ) ,
172+ pauseTimeout : ( ) => clearTimeout ( timeout ) ,
173+ resetIdleTimeout : ( ) =>
174+ armTimer (
175+ new BailianError (
176+ `Response body idle timeout: no data received for ${ timeoutMs / 1000 } s.` ,
177+ ExitCode . TIMEOUT ,
178+ "Increase --timeout to allow a longer interval between response chunks." ,
179+ ) ,
180+ ) ,
181+ } ;
182+ }
144183
145- return { signal : controller . signal , cleanup } ;
184+ function protectResponseBody ( response : Response , requestSignal : RequestSignal ) : Response {
185+ if ( ! response . body ) {
186+ requestSignal . cleanup ( ) ;
187+ return response ;
188+ }
189+ // Only count time spent waiting for network data, not consumer processing.
190+ // An unused raw response must not keep a command alive through a timer.
191+ requestSignal . pauseTimeout ( ) ;
192+ const reader = response . body . getReader ( ) ;
193+ const { signal } = requestSignal ;
194+ let finished = false ;
195+ let onAbort : ( ) => void ;
196+ const finish = ( ) => {
197+ finished = true ;
198+ signal . removeEventListener ( "abort" , onAbort ) ;
199+ requestSignal . cleanup ( ) ;
200+ } ;
201+ const cancelSource = ( reason ?: unknown ) =>
202+ reader . cancel ( reason ) . finally ( ( ) => reader . releaseLock ( ) ) ;
203+ const body = new ReadableStream < Uint8Array > (
204+ {
205+ start ( controller ) {
206+ onAbort = ( ) => {
207+ if ( finished ) return ;
208+ finish ( ) ;
209+ controller . error ( signal . reason ) ;
210+ void cancelSource ( signal . reason ) . catch ( ( ) => { } ) ;
211+ } ;
212+ signal . addEventListener ( "abort" , onAbort , { once : true } ) ;
213+ if ( signal . aborted ) onAbort ( ) ;
214+ } ,
215+ async pull ( controller ) {
216+ try {
217+ requestSignal . resetIdleTimeout ( ) ;
218+ const part = await reader . read ( ) ;
219+ requestSignal . pauseTimeout ( ) ;
220+ if ( finished ) return ;
221+ if ( part . done ) {
222+ finish ( ) ;
223+ reader . releaseLock ( ) ;
224+ controller . close ( ) ;
225+ } else {
226+ controller . enqueue ( part . value ) ;
227+ }
228+ } catch ( error ) {
229+ if ( finished ) return ;
230+ finish ( ) ;
231+ reader . releaseLock ( ) ;
232+ controller . error ( signal . aborted ? signal . reason : error ) ;
233+ }
234+ } ,
235+ cancel ( reason ) {
236+ finish ( ) ;
237+ // Own transport teardown as well as reader cleanup: cancelling the
238+ // reader alone can leave native Bun's HTTP connection open.
239+ requestSignal . abort ( reason ) ;
240+ return cancelSource ( reason ) . catch ( ( ) => { } ) ;
241+ } ,
242+ } ,
243+ { highWaterMark : 0 } ,
244+ ) ;
245+ const protectedResponse = new Response ( body , {
246+ status : response . status ,
247+ statusText : response . statusText ,
248+ headers : response . headers ,
249+ } ) ;
250+ // A constructed Response otherwise loses fetch's URL/redirect metadata.
251+ Object . defineProperties ( protectedResponse , {
252+ url : { value : response . url } ,
253+ redirected : { value : response . redirected } ,
254+ type : { value : response . type } ,
255+ } ) ;
256+ return protectedResponse ;
146257}
147258
148259export async function requestJson < T > ( deps : HttpDeps , opts : RequestOpts ) : Promise < T > {
0 commit comments