Skip to content
ferret.
Docs
Your first hunt

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.

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.

terminal
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.

src/lib/user-cache.test.ts
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);
});
vitest run
FAIL src/lib/user-cache.test.ts > a failed fetch does not poison the cache
AssertionError: promise resolved "[Error: network blip]" instead of rejecting
second get(id) replayed the cached rejection
expected fetchUser to be called 2 times, got 1

The 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.

diff · src/lib/user-cache.ts
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.