One real bug, start to finish.
We will hunt a defect that hides in about a dozen lines: an in-flight request cache that de-duplicates concurrent calls but poisons itself when one of them fails. Follow along in any TypeScript repo.
The code under the hunt
Here is the file. It looks correct, and it passes every test that has a working network. Save it as src/lib/user-cache.ts.
const inflight = new Map<string, Promise<User>>(); export function createUserCache(fetchUser: (id: string) => Promise<User>) { return { get(id: string): Promise<User> { const pending = inflight.get(id); if (pending) return pending; const p = fetchUser(id); inflight.set(id, p); // BUG: delete runs only if the await resolves. A rejected // fetch is never cleared, so it is cached and replayed. return p.then((user) => { inflight.delete(id); return user; }); }, };}Run the hunt
Point Ferret at the file. It builds a model of the map entry's lifetime and notices there is no path that clears it on rejection.
npx ferret hunt src/lib/user-cache.ts 1 defect caught ✗ user-cache.ts:14 unhandled rejection · poisoned cache the in-flight entry is only deleted on the resolve branch; a rejected fetch stays cached and is replayed forever.The failing test it wrote
Ferret writes a reproduction in your framework. It fails on the current code exactly the way production does: the second call replays the cached rejection instead of retrying.
import { expect, test, vi } from "vitest";import { createUserCache } from "./user-cache"; test("a failed fetch does not poison the cache", async () => { let attempts = 0; const fetchUser = vi.fn(async (id: string) => { attempts++; if (attempts === 1) throw new Error("network blip"); return { id, name: "Ada" }; }); const cache = createUserCache(fetchUser); // First call fails on the transient error. await expect(cache.get("u1")).rejects.toThrow("network blip"); // Second call must retry, not replay the cached rejection. await expect(cache.get("u1")).resolves.toEqual({ id: "u1", name: "Ada" }); expect(fetchUser).toHaveBeenCalledTimes(2);});FAIL src/lib/user-cache.test.ts > a failed fetch does not poison the cacheAssertionError: promise resolved "[Error: network blip]" instead of rejecting → second get(id) replayed the cached rejection → expected fetchUser to be called 2 times, got 1The fix it proposed
The patch clears the entry whether the fetch resolves or rejects, so a transient failure is retried instead of cached. Apply it with npx ferret apply, which opens a branch with the test and the fix in separate commits.
const pending = inflight.get(id); if (pending) return pending; const p = fetchUser(id); inflight.set(id, p); return p.then((user) => { inflight.delete(id); return user; }); // Clear the entry whether the fetch resolves or rejects, // so a transient failure is retried instead of cached. const p = fetchUser(id).finally(() => { inflight.delete(id); }); inflight.set(id, p); return p;✓ Passes. fetchUser called 2 times, cache retries after the blip.
Ship it
Review the two commits, merge, and add the GitHub Action so the next one gets caught on the pull request instead of in an incident channel. That is the whole loop.