( url: RequestInfo, options: RequestInit, timeout: number, maxRetries: number | undefined, isIdempotent: boolean, )
| 595 | } |
| 596 | |
| 597 | async function fetchAndReadBody( |
| 598 | url: RequestInfo, |
| 599 | options: RequestInit, |
| 600 | timeout: number, |
| 601 | maxRetries: number | undefined, |
| 602 | isIdempotent: boolean, |
| 603 | ): Promise<{ respText: string; resp: Response; fetchLatencyMs: number }> { |
| 604 | const maxBodyRetries = isIdempotent ? 2 : 0; |
| 605 | for (let bodyAttempt = 0; bodyAttempt <= maxBodyRetries; bodyAttempt++) { |
| 606 | const fetchStart = Date.now(); |
| 607 | // fetchWithRetries errors propagate directly — not caught by body retry |
| 608 | const resp = await fetchWithRetries(url, options, timeout, maxRetries); |
| 609 | const fetchLatencyMs = Date.now() - fetchStart; |
| 610 | |
| 611 | try { |
| 612 | const respText = await resp.text(); |
| 613 | return { respText, resp, fetchLatencyMs }; |
| 614 | } catch (err) { |
| 615 | if (isTransientConnectionError(err as Error) && bodyAttempt < maxBodyRetries) { |
| 616 | const backoffMs = Math.pow(2, bodyAttempt) * 1000; |
| 617 | logger.debug('[Cache] Body stream failed with transient error, retrying', { |
| 618 | attempt: bodyAttempt + 1, |
| 619 | maxRetries: maxBodyRetries, |
| 620 | backoffMs, |
| 621 | error: (err as Error)?.message?.slice(0, 200), |
| 622 | }); |
| 623 | await sleep(backoffMs); |
| 624 | continue; |
| 625 | } |
| 626 | // Preserve cancellation: an aborted body read rejects with an AbortError, and |
| 627 | // callers (e.g. evaluator.ts) suppress expected cancellation by checking |
| 628 | // `err.name === 'AbortError'`. Wrapping it would reset the name to 'Error' and |
| 629 | // turn cancelled evals into ordinary provider failures, so rethrow aborts as-is. |
| 630 | if (isAbortError(err)) { |
| 631 | throw err; |
| 632 | } |
| 633 | // Surface the URL and HTTP response context so opaque body-read failures |
| 634 | // (e.g. "TypeError: terminated" from a Cloudflare-originated 403) include |
| 635 | // actionable diagnostics instead of a bare platform error. Sanitize the URL so |
| 636 | // credential-bearing userinfo / query params are not leaked into logs. |
| 637 | const wrappedError = new Error( |
| 638 | `Error reading response body from ${sanitizeUrl(getRequestUrlString(url))}: ${ |
| 639 | (err as Error).message |
| 640 | }. HTTP ${resp.status} ${resp.statusText}`, |
| 641 | ) as Error & { cause?: unknown }; |
| 642 | wrappedError.cause = err; |
| 643 | throw wrappedError; |
| 644 | } |
| 645 | } |
| 646 | // Unreachable: loop always returns or throws, but TypeScript needs this |
| 647 | throw new Error('Exhausted body retries without returning or throwing'); |
| 648 | } |
| 649 | |
| 650 | async function prepareFetchResponse( |
| 651 | url: RequestInfo, |
no test coverage detected
searching dependent graphs…