(
method: string,
path: string,
options: RequestOptions = {},
)
| 405 | } |
| 406 | |
| 407 | async requestWithMeta<T>( |
| 408 | method: string, |
| 409 | path: string, |
| 410 | options: RequestOptions = {}, |
| 411 | ): Promise<RequestResult<T>> { |
| 412 | if (!this.apiKey) throw ApiError.authRequired(); |
| 413 | |
| 414 | const url = buildUrl(this.baseUrl, path, options.query); |
| 415 | const requestId = options.requestId ?? newRequestId(); |
| 416 | |
| 417 | let attempt = 0; |
| 418 | while (true) { |
| 419 | attempt += 1; |
| 420 | this.debug({ kind: 'request', method, url, attempt, requestId }); |
| 421 | const startedAt = Date.now(); |
| 422 | let response: Response; |
| 423 | |
| 424 | // Compose the per-request timeout signal with any caller-supplied signal. |
| 425 | // The fetch aborts on whichever fires first. This ensures every one-shot |
| 426 | // request (test create/update/delete/list/get, auth whoami, code put/get, |
| 427 | // plan put) has a client-side deadline even when the caller supplies no |
| 428 | // signal. The polling path supplies its own deadline-aware signal per |
| 429 | // iteration — this timeout (120s default) is safely larger than any single |
| 430 | // long-poll window (<=25s via ?waitSeconds), so it never bites polling. |
| 431 | const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs); |
| 432 | const effectiveSignal = |
| 433 | options.signal != null ? AbortSignal.any([timeoutSignal, options.signal]) : timeoutSignal; |
| 434 | |
| 435 | try { |
| 436 | response = await this.fetchImpl(url, { |
| 437 | method, |
| 438 | headers: this.buildHeaders(requestId, options), |
| 439 | body: options.body !== undefined ? JSON.stringify(options.body) : undefined, |
| 440 | signal: effectiveSignal, |
| 441 | }); |
| 442 | } catch (err) { |
| 443 | // Distinguish a client-side request timeout from a caller-supplied abort. |
| 444 | // |
| 445 | // Node 22 `AbortSignal.timeout()` throws a `DOMException` with |
| 446 | // `name === 'TimeoutError'` (not 'AbortError') when the signal fires. |
| 447 | // A caller-supplied abort sets `name === 'AbortError'`. |
| 448 | // We treat both abort variants together: if the timeout signal fired and |
| 449 | // the caller hadn't already aborted, surface a clear RequestTimeoutError. |
| 450 | // A timeout/abort during the fetch itself: classify it (RequestTimeoutError |
| 451 | // when our deadline fired; otherwise rethrow the caller's abort unmodified). |
| 452 | this.rethrowIfAbort(err, timeoutSignal, options.signal, requestId); |
| 453 | // If a RequestTimeoutError already propagated from somewhere (e.g. from a |
| 454 | // nested call or from a test-injected fetchImpl), pass it through unchanged |
| 455 | // rather than re-wrapping it as a TransportError. |
| 456 | if (err instanceof RequestTimeoutError) throw err; |
| 457 | const message = err instanceof Error ? err.message : String(err); |
| 458 | this.debug({ |
| 459 | kind: 'error', |
| 460 | method, |
| 461 | url, |
| 462 | attempt, |
| 463 | requestId, |
| 464 | errorCode: 'TRANSPORT', |
no test coverage detected