Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | 15x 1x 1x 1x 1x 1x 1x 1x 15x 828x 15x 15x 15x 15x 15x 15x 15x 15x 57x 15x 57x 28x 28x 28x 28x 22x 22x 22x 22x 22x 66x 66x 66x 30x 36x 36x 66x 66x 66x 66x 66x 66x 8x 28x 28x 11x 11x 3x 8x 28x 145x 145x 18x 127x 61x 66x 66x 96x 96x 61x 78x 1312x 1381x 1363x 1285x 78x 78x 1381x 8x 8x 70x 1381x 1381x 1381x 33x | import {
RATE_LIMIT_BASE_DELAY_MS,
RATE_LIMIT_MAX_DELAY_MS,
RATE_LIMIT_MAX_RETRIES,
} from '../consts';
import { warn } from './logger';
interface RetryTiming {
/** Wait `ms` milliseconds, rejecting with the signal's reason if it aborts first. */
sleep: (ms: number, signal?: AbortSignal | null) => Promise<void>;
/** A number in [0, 1), as `Math.random` returns. Drives the backoff jitter. */
random: () => number;
/** The current time in milliseconds, on the clock a `deadline` is measured on. */
now: () => number;
}
interface RetryOptions {
/**
* When the caller's timeout expires, as a {@link RetryTiming.now} time. A retry whose wait
* would not end before it is not attempted: the 429 is returned instead.
*/
deadline?: number;
timing?: RetryTiming;
/** Called with each 429 about to be retried, before the wait. */
onRetry?: (response: Response) => void;
}
const sleep = (ms: number, signal?: AbortSignal | null): Promise<void> =>
new Promise((resolve, reject) => {
Iif (signal?.aborted) {
reject(signal.reason);
return;
}
const onAbort = (): void => {
clearTimeout(timer);
reject(signal?.reason);
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
/**
* The timing every retry uses unless given its own. Shared and mutable so tests can replace
* the clock and the waits of a whole toolset call, deadline included.
*/
export const retryTiming: RetryTiming = {
sleep,
random: () => Math.random(),
now: () => performance.now(),
};
// The three HTTP-date forms of RFC 9110 §5.6.7, exactly: case-sensitive, single spaces, GMT only.
const WEEKDAY = '(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)';
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const MONTH = `(?<month>${MONTHS.join('|')})`;
const TIME = '(?<hour>\\d{2}):(?<minute>\\d{2}):(?<second>\\d{2})';
/** `Sun, 06 Nov 1994 08:49:37 GMT`: RFC 9110's preferred form. */
const IMF_FIXDATE = new RegExp(`^${WEEKDAY}, (?<day>\\d{2}) ${MONTH} (?<year>\\d{4}) ${TIME} GMT$`);
/** `Sunday, 06-Nov-94 08:49:37 GMT`: obsolete, with a two-digit year. */
const RFC850_DATE = new RegExp(
`^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?<day>\\d{2})-${MONTH}-(?<year>\\d{2}) ${TIME} GMT$`,
);
/** `Sun Nov 6 08:49:37 1994`: obsolete, C's asctime(), always GMT. */
const ASCTIME_DATE = new RegExp(
`^${WEEKDAY} ${MONTH} (?<day> \\d|\\d{2}) ${TIME} (?<year>\\d{4})$`,
);
const isLeapYear = (year: number): boolean =>
(year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
const daysInMonth = (year: number, month: number): number =>
[31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month] ?? 0;
/** A UTC time as milliseconds since the epoch. `setUTCFullYear`, as `Date.UTC` reads 0–99 as 19xx. */
function utcTime(
year: number,
month: number,
day: number,
h: number,
m: number,
s: number,
): number {
const date = new Date(0);
date.setUTCFullYear(year, month, day);
date.setUTCHours(h, m, s, 0);
return date.getTime();
}
/** `time` moved by whole years; 29 February lands on the 28th in a common year. */
function addYears(time: number, years: number): number {
const date = new Date(time);
const year = date.getUTCFullYear() + years;
const day = Math.min(date.getUTCDate(), daysInMonth(year, date.getUTCMonth()));
date.setUTCFullYear(year, date.getUTCMonth(), day);
return date.getTime();
}
/**
* Read an RFC 9110 HTTP-date as a time in milliseconds, or undefined if it is not one.
*
* Strict, unlike `Date.parse`, which reads a zone-less date in the host's local time, rolls
* 30 February over to 2 March and accepts trailing text. All three forms are always GMT. The
* weekday must be present but need not match the date, which is what the date means.
*/
function parseHttpDate(value: string, now: number): number | undefined {
const rfc850 = RFC850_DATE.exec(value);
const fields = (rfc850 ?? IMF_FIXDATE.exec(value) ?? ASCTIME_DATE.exec(value))?.groups;
if (!fields) {
return undefined;
}
const thisYear = new Date(now).getUTCFullYear();
const year = Number(fields.year) + (rfc850 ? thisYear - (thisYear % 100) : 0);
const month = MONTHS.indexOf(fields.month ?? '');
const d = Number(fields.day);
const h = Number(fields.hour);
const m = Number(fields.minute);
const s = Number(fields.second);
// Second 60 is a leap second: the instant the next minute starts.
if (year < 1 || d < 1 || d > daysInMonth(year, month) || h > 23 || m > 59 || s > 60) {
return undefined;
}
let when = utcTime(year, month, d, h, m, s);
if (rfc850) {
// RFC 9110 §5.6.7: a two-digit year more than 50 years ahead is the most recent past year
// with those digits.
const horizon = addYears(now, 50);
if (when > horizon) {
when = addYears(when, -100);
I} else if (addYears(when, 100) <= horizon) {
when = addYears(when, 100);
}
}
return when;
}
/**
* How long the server asked us to wait, in milliseconds: `Retry-After` as delta-seconds or an
* HTTP-date, read against `now` (milliseconds since the epoch). Undefined when the header is
* absent or unreadable, so the caller backs off instead. Not capped: the caller caps it.
*/
export function retryAfterMs(header: string | null, now: number = Date.now()): number | undefined {
const value = header?.trim();
if (!value) {
return undefined;
}
if (/^\d+$/.test(value)) {
return Number(value) * 1000;
}
const date = parseHttpDate(value, now);
return date === undefined ? undefined : Math.max(0, date - now);
}
/**
* How long to wait, in milliseconds, before retry number `retry` (1-based) of a 429: its
* `Retry-After` when readable, else 1s, 2s, 4s jittered by `random()` into [50%, 100%); capped
* at 30s either way.
*/
export function rateLimitDelayMs(
retry: number,
retryAfter: number | undefined,
random: () => number,
): number {
const delay = retryAfter ?? RATE_LIMIT_BASE_DELAY_MS * 2 ** (retry - 1) * (0.5 + random() * 0.5);
return Math.min(delay, RATE_LIMIT_MAX_DELAY_MS);
}
/**
* Whether a retry's wait ends before the deadline, `remaining` milliseconds away. An equal wait
* does not: the caller's timeout would fire as the retry starts.
*/
export const waitsForRetry = (delay: number, remaining: number): boolean => delay < remaining;
/** Seconds rounded half up to two decimals and written as a number: `0`, `0.75`, `1.5`. */
const formatSeconds = (ms: number): string => String(Math.floor((ms / 1000) * 100 + 0.5) / 100);
/**
* `fetch`, retried on HTTP 429.
*
* A 429 means the server refused before doing anything, so even a `tools/call` is safe to send
* again. Each retry waits for the response's `Retry-After` (capped at 30s), or else 1s, 2s, 4s
* with jitter. Every other status, and the last 429, is handed back as it came: the caller turns
* it into a `StackOneAPIError` with the server's body.
*
* A wait that would not end before `deadline` is not started: the 429 is handed back at once.
* Waiting anyway would only let the caller's timeout fire, turning a rate limit — which fails a
* multi-account call — into a timeout, which skips one account and returns a partial result.
*/
export async function fetchWithRetry(
input: string | URL,
init?: RequestInit,
{ deadline, timing = retryTiming, onRetry }: RetryOptions = {},
): Promise<Response> {
for (let retry = 1; ; retry++) {
const response = await fetch(input, init);
if (response.status !== 429 || retry > RATE_LIMIT_MAX_RETRIES) {
return response;
}
const delay = rateLimitDelayMs(
retry,
retryAfterMs(response.headers.get('retry-after')),
timing.random,
);
const limited = `${init?.method ?? 'GET'} ${String(input)} was rate limited (429) on attempt ${retry} of ${RATE_LIMIT_MAX_RETRIES + 1}`;
if (deadline !== undefined && !waitsForRetry(delay, deadline - timing.now())) {
warn(
`${limited}; not retrying, because waiting ${formatSeconds(delay)}s would pass the deadline`,
);
return response;
}
// Discarded unread: only the final 429's body is reported. Not awaited, since a cancel can
// wait on a producer that never settles (MSW's, for one), and the retry need not wait.
void response.body?.cancel().catch(() => undefined);
onRetry?.(response);
warn(`${limited}; retrying in ${formatSeconds(delay)}s`);
if (delay > 0) {
await timing.sleep(delay, init?.signal);
}
}
}
|