(input: RetryRequestInput)
| 133 | } |
| 134 | |
| 135 | async function fetchWithRetry(input: RetryRequestInput): Promise<void> { |
| 136 | let lastError: unknown |
| 137 | for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { |
| 138 | if (input.signal.aborted) throw input.signal.reason ?? new Error('Aborted') |
| 139 | const perAttempt = AbortSignal.any([input.signal, AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS)]) |
| 140 | let response: Response |
| 141 | try { |
| 142 | const headers = await input.buildHeaders() |
| 143 | response = await fetch(input.url, { |
| 144 | method: input.method, |
| 145 | body: input.body as BodyInit | undefined, |
| 146 | headers, |
| 147 | signal: perAttempt, |
| 148 | }) |
| 149 | } catch (error) { |
| 150 | lastError = error |
| 151 | logger.debug('GCS request failed', { |
| 152 | action: input.action, |
| 153 | attempt, |
| 154 | bucket: input.bucket, |
| 155 | error: toError(error).message, |
| 156 | }) |
| 157 | if (attempt < MAX_ATTEMPTS) { |
| 158 | await sleepUntilAborted(backoffWithJitter(attempt, null), input.signal) |
| 159 | continue |
| 160 | } |
| 161 | throw error |
| 162 | } |
| 163 | if (response.ok || input.successStatuses?.includes(response.status)) { |
| 164 | /** Drain the success body so undici can return the socket to the keep-alive pool. */ |
| 165 | await response.text().catch(() => '') |
| 166 | return |
| 167 | } |
| 168 | if (!isRetryableStatus(response.status) || attempt === MAX_ATTEMPTS) { |
| 169 | const text = await response.text().catch(() => '') |
| 170 | logger.warn('GCS operation failed', { |
| 171 | action: input.action, |
| 172 | bucket: input.bucket, |
| 173 | status: response.status, |
| 174 | }) |
| 175 | throw new Error( |
| 176 | `GCS ${input.action} failed (HTTP ${response.status}): ${text || response.statusText}` |
| 177 | ) |
| 178 | } |
| 179 | lastError = new Error(`GCS ${input.action} responded with HTTP ${response.status}`) |
| 180 | const retryAfterMs = parseRetryAfter(response.headers.get('retry-after')) |
| 181 | /** Drain the retryable response body so undici can return the socket to the keep-alive pool. */ |
| 182 | await response.text().catch(() => '') |
| 183 | await sleepUntilAborted(backoffWithJitter(attempt, retryAfterMs), input.signal) |
| 184 | } |
| 185 | throw lastError instanceof Error |
| 186 | ? lastError |
| 187 | : new Error(`GCS ${input.action} failed after retries`) |
| 188 | } |
| 189 | |
| 190 | /** GCS uses HTTP headers (x-goog-meta-*) to carry custom metadata; the spec forbids non-ASCII. */ |
| 191 | const ASCII_ONLY_RE = /^[\x20-\x7e]*$/ |
no test coverage detected