(
url: string,
init: RequestInit & { maxWaitMs?: number } = {},
)
| 140 | * Bulk scripts can pass a larger budget. |
| 141 | */ |
| 142 | export async function fetchWithRateLimit( |
| 143 | url: string, |
| 144 | init: RequestInit & { maxWaitMs?: number } = {}, |
| 145 | ): Promise<Response> { |
| 146 | const maxWaitMs = init.maxWaitMs ?? 30_000; |
| 147 | const maxAttempts = 4; |
| 148 | let totalWait = 0; |
| 149 | let lastRes: Response | null = null; |
| 150 | |
| 151 | for (let attempt = 1; attempt <= maxAttempts; attempt++) { |
| 152 | const res = await fetch(url, init); |
| 153 | lastRes = res; |
| 154 | |
| 155 | if (res.status !== 429 && res.status !== 403) return res; |
| 156 | |
| 157 | // Distinguish "rate-limited" 403 from "forbidden" 403 by checking the |
| 158 | // X-RateLimit-Remaining header. A real auth/permission 403 has remaining > 0. |
| 159 | const remaining = Number(res.headers.get("x-ratelimit-remaining") ?? ""); |
| 160 | if (res.status === 403 && Number.isFinite(remaining) && remaining > 0) { |
| 161 | return res; |
| 162 | } |
| 163 | |
| 164 | const retryAfter = Number(res.headers.get("retry-after") ?? ""); |
| 165 | const reset = Number(res.headers.get("x-ratelimit-reset") ?? "0") * 1000; |
| 166 | let waitMs = |
| 167 | Number.isFinite(retryAfter) && retryAfter > 0 |
| 168 | ? retryAfter * 1000 |
| 169 | : Math.max(reset - Date.now(), 1000); |
| 170 | waitMs = Math.min(waitMs, maxWaitMs - totalWait); |
| 171 | |
| 172 | if (waitMs <= 0 || attempt === maxAttempts) return res; |
| 173 | |
| 174 | totalWait += waitMs; |
| 175 | await sleep(waitMs); |
| 176 | } |
| 177 | |
| 178 | return lastRes as Response; |
| 179 | } |
| 180 | |
| 181 | /** |
| 182 | * Caller-controlled retry budget for GitHub API rate-limits. |
no test coverage detected