sindresorhus/ky · Version audited: v2.2.0 (branch main) · Agent-Readiness Score: 80 / 100
Deep Codebase Audit: sindresorhus/ky
This is an unedited example of what a $9 RepoRoast Deep Codebase Audit delivers: implementation-level behavioral findings with exact file:line citations, a repository-specific AGENTS.md replacement, and a runnable regression test file.
1. Implementation & Behavioral Findings
Retry-After exceeding remaining total timeout sleeps the dead budget and erases HTTPError(429)
Location: source/core/Ky.ts:1128-1140, interacting with source/core/retry-timing.ts:26-29, 152-164 and source/utils/normalize.ts:30.
Mechanism: Under Ky v2's default 10,000 ms total timeout, suppose an API responds in 50 ms with HTTP 429 and a routine Retry-After: 60 header (60,000 ms). In #retryFromError (source/core/Ky.ts:1137-1140):
// source/core/Ky.ts:1136-1140
// If waiting would consume all remaining budget, time out without starting another request.
if (retryDelay >= remainingTimeout) {
await delay(remainingTimeout, delayOptions);
throw new TimeoutError(this.#sentRequest);
}
Impact (two distinct failure modes):
1. Dead wait + lost diagnostic context: Even though Ky already knows at t = 50ms that 60_000 >= 9_950 and no second request will ever be attempted, it blocks the caller for the remaining 9.95 seconds doing nothing and then throws TimeoutError—discarding the HTTPError, its 429 status code, and the rate-limit response body/headers.
2. 24.8-day fiber hang when timeout: false: If a caller disables total timeout (timeout: false) and a reverse proxy misconfigures Retry-After: 1793491200 (sending a UNIX epoch instead of delta-seconds, which retry-timing.ts:28 treats as seconds via allowTimestamp: false), default maxRetryAfter is Infinity, so Ky.ts:1128 clamps to maxSafeTimeout (2_147_483_647 ms) and sleeps the Promise for 24.85 days unless the caller also explicitly set retry.maxRetryAfter.
Retry-After blocks fallback to valid RateLimit-Reset
Location: source/core/retry-timing.ts:3, 25-49 and source/core/Ky.ts:738-744.
Details: getRetryTimingHeader returns immediately whenever headers.get('Retry-After') !== null—even if Retry-After is empty ("") or malformed—preventing inspection of a valid RateLimit-Reset header on the same response, falling straight back to exponential backoff (Ky.ts:743). While L31 notes Retry-After is authoritative when present, any coding agent asked to "add fallback rate-limit header support" is highly likely to alter this precedence unless explicitly documented in AGENTS.md.
source/core/Ky.ts + 1-paragraph AGENTS.md without single-file test commands
Details: The current AGENTS.md (622 bytes) documents only the null vs undefined convention. Meanwhile, npm test runs four sequential gates (xo && npm run build && tsc --project tsconfig.test.json && ava), and source/core/Ky.ts is 53,421 bytes (~1,450 lines). Coding agents without explicit single-file AVA instructions repeatedly run the full build+lint+type+browser matrix on every edit, burning 5–8x tokens and risking accidental regressions across replaceSymbol (Symbol.for('ky.replaceOption')) and deletedParametersSymbol on URLSearchParams.
2. Tailored Drop-In AGENTS.md for sindresorhus/ky
Preserves Sindre's exact strict-undefined rule verbatim while adding fast-loop AVA verification, build prerequisites, and subtle cross-module contracts discovered in source/:
# Conventions & Agent Engineering Guide
## Strict `undefined` Policy (Critical)
- `null` is not an accepted value anywhere in this project. Options, hook lists, arguments and hook state all use `undefined` to mean absent, and passing `null` is a mistake that should be reported as one rather than quietly treated as `undefined`.
- Do not add `null`-tolerant branches, do not widen a type to include `null`, and do not default a `null` parameter to another value. When a caller can plausibly write `null`, the fix is a clear error that names the option, not a fallback.
- The one exception is the `signal` option, which accepts `null` for `RequestInit` compatibility and treats it as absent.
## Fast Verification Commands (Node >= 22)
- Run a single AVA test file during iteration: `npx ava test/retry.ts`
- Run a specific test by title: `npx ava test/retry.ts -m '*Retry-After*'`
- Check type tests (`tsd` / test tsconfig): `npx tsc --project tsconfig.test.json`
- Lint & style (`xo`): `npx xo`
- Full pre-PR gate (required before finishing): `npm test` (`xo && npm run build && tsc --project tsconfig.test.json && ava`)
## Architectural Invariants (Do Not Break)
- **Unbound `fetch` invocation (`source/utils/timeout.ts:32-33`):** Always extract `const {fetch} = options; fetch(...)` rather than calling `options.fetch(...)` so native browser `window.fetch` never receives `options` as `this` ("Illegal invocation").
- **Cross-copy symbol identity (`source/utils/merge.ts:7`):** `replaceOption` uses `Symbol.for('ky.replaceOption')` intentionally so duplicated dependency copies recognize markers; never change it to an unregistered `Symbol()`.
- **`URLSearchParams` deletion tracking (`source/utils/merge.ts:100-109`):** `cloneShallow` and `cloneSearchParameters` must preserve `deletedParametersSymbol` sets so `init` hooks do not resurrect deleted query params.
- **Retry timing precedence (`source/core/retry-timing.ts:25-49`):** `Retry-After` is authoritative over `RateLimit-Reset` when present, and UNIX epoch interpretation (`>= 1_000_000_000`) is enabled only on reset headers (`allowTimestamp: true`), never on `Retry-After`.
- **Editing `source/core/Ky.ts` (53 KB):** Inspect targeted line ranges around `#retryFromError`, `#calculateRetryDelay`, and `#fetch` rather than regenerating whole-file replacements.
3. Runnable AVA Edge-Case Suite (test/retry-timing-edge-cases.ts)
Drop into test/ and run via npx ava test/retry-timing-edge-cases.ts to lock down retry-header precedence, leap-second IMF dates, RFC 850 50-year century rollover, and budget-exhaustion behavior:
import test from 'ava';
import {
calculateRetryTimingDelay,
getRetryTimingHeader,
} from '../source/core/retry-timing.js';
test('Retry-After takes precedence over RateLimit-Reset even when Retry-After is malformed', t => {
const headers = new Headers({
'Retry-After': 'not-a-valid-delay',
'RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 5),
});
const header = getRetryTimingHeader(headers);
t.deepEqual(header, {value: 'not-a-valid-delay', allowTimestamp: false});
t.is(calculateRetryTimingDelay(header!), undefined);
});
test('Retry-After numeric value above 1e9 is treated as delta-seconds, not UNIX epoch', t => {
const futureEpoch = Math.floor(Date.now() / 1000) + 10;
const retryAfterHeader = getRetryTimingHeader(new Headers({'Retry-After': String(futureEpoch)}))!;
const resetHeader = getRetryTimingHeader(new Headers({'RateLimit-Reset': String(futureEpoch)}))!;
const retryAfterMs = calculateRetryTimingDelay(retryAfterHeader)!;
const resetMs = calculateRetryTimingDelay(resetHeader)!;
t.true(retryAfterMs >= futureEpoch * 1000);
t.true(resetMs >= 0 && resetMs <= 15_000);
});
test('IMF-fixdate leap second (:60) parses without Date.UTC overflow rejection', t => {
const header = getRetryTimingHeader(
new Headers({'Retry-After': 'Wed, 31 Dec 2098 23:59:60 GMT'}),
)!;
const delayMs = calculateRetryTimingDelay(header);
t.is(typeof delayMs, 'number');
t.true(Number.isFinite(delayMs) && delayMs! > 0);
});
test('calendar overflow date (31 Feb) is rejected cleanly as undefined', t => {
const header = getRetryTimingHeader(
new Headers({'Retry-After': 'Wed, 31 Feb 2028 12:00:00 GMT'}),
)!;
t.is(calculateRetryTimingDelay(header), undefined);
});