The dedupe key was set before the fetch, so it never retried
One connection refusal during gateway boot silenced the side panel for that URL forever: the dedupe key was committed before the request settled.
The document-match box in our Chrome side panel was empty on a page I knew had three matching documents. Reloading did nothing. Reopening the panel did nothing. Restarting the browser did nothing. Changing the tab’s title fixed it instantly, which is the kind of clue that tells you the bug is not where you have been looking for three days.
The gateway wasn’t listening yet, and that URL went silent until its title changed
extension/Store-vodou-bridge/sidepanel.js keeps a one-line dedupe guard so that a panel refresh on the same page does not re-hit /api/library/match. The key is the URL plus the query text, and the query text is built out of the page title:
const query = [(tab.title || '').trim(), hostOf(url)].filter(Boolean).join(' ').trim();
const key = [url, query].join('|');
That is why retyping the tab’s title “fixed” it. Editing the title changed query, which changed key, which made the guard miss, which let a request actually go out. Nothing about the matcher got better; the guard just stopped short-circuiting.
The problem was where the key got written. It was written on the way in, above the fetch, not on the way out. So the sequence was: gateway still booting, nothing listening on the port, fetch rejects with TypeError: Failed to fetch, control lands in the catch (not in the if (!res.ok) branch. There was no response to have a status), the catch hides the box, and the key for that page is now permanently marked as asked-and-answered. Every later refresh compared the key, found it unchanged, and returned before making a request.
I had actually written this down on 2026-08-17, lastQuery is committed before the fetch, so any failure permanently poisons that page until its title changes, and then went and looked at the retrieval scoring anyway, because I was sure the matcher was the flaky part. It was not. The matcher never ran. (The line number in that old note is worthless now; the file moved by several hundred lines between then and the fix, so I’m citing only the line numbers in the file as it stands today.)
The fix is a two-line move, from line 1835 to line 1866
const key = [url, query].join('|');
if (!query || key === lastQuery) return;
// lastQuery = key; <-- was here. commits on the ATTEMPT.
try {
const res = await fetch(gatewayBase() + '/api/library/match', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query, topK: 3 }),
});
if (!res.ok) { box.hidden = true; return; } // no commit: still retryable
const data = await res.json(); // (a 404-means-route-missing branch elided)
lastQuery = key; // commits on the RESULT
render((data && data.matches) || []);
} catch (_) {
box.hidden = true; // no commit here either
}
The guard is at line 1835, the commit is at line 1866, and every path between them that does not produce parsed JSON leaves the key untouched. That is the whole poisoning fix, and it is real: a later refresh now retries.
An empty state indistinguishable from a correct empty state
Read that diff again, though, because it does not fix what actually cost me the three days. Both failure paths still say box.hidden = true. A hidden box is exactly what “no documents matched” looks like. The stale key is why the panel never recovered; the identical rendering is why I spent three days debugging the scorer instead of the transport. Fixing the first and shipping it, which is what I did, leaves the second bug on the floor.
The remedy is that a failure must render a different thing, not a smaller thing:
function unknown(why) { // NOT box.hidden = true
list.innerHTML = '<div style="font-size:11px;opacity:.6;padding:6px 0">'
+ esc("couldn't check documents — " + why) + '</div>';
box.hidden = false;
}
if (!res.ok) { unknown('gateway said ' + res.status); return; }
// ...
} catch (e) {
unknown('gateway unreachable');
}
Three states, not two: matches, no matches, couldn’t check. An operator staring at “couldn’t check documents: gateway unreachable” walks to the gateway. An operator staring at an empty box walks to the ranker.
The other missing instrument is even cheaper. The early return at line 1835 writes nothing, logs nothing, and leaves no trace that a refresh happened at all, from the outside it is indistinguishable from the panel not running. One console.debug on that branch, printing the key it matched on, would have shown me a page skipping every refresh on a key that had never once been answered, and I would have found this in ten minutes.
The class: a sentinel written by the attempt, not by the result
This is not a Chrome bug or a fetch bug. It is any dedupe or memoization guard that records its sentinel before verifying the operation it guards succeeded. One transient failure becomes a permanently cached empty state, because every subsequent call short-circuits on a key that never earned a value. You will find the same shape in a React Query or SWR key marked as fetched on a rejected request, in a LangChain or LlamaIndex embedding cache that stores the key before the provider call returns, and in an MCP server or API client doing request coalescing where the in-flight map entry outlives the failure.
Those last two are not the same rule, and I had them collapsed into one. Split it:
A value cache commits on the result. The key must be written by the code path that produced the value, and every early return between the guard and that write must leave the structure untouched.
An in-flight / coalescing map commits on the attempt, that is the entire point, since the second caller has to find the first caller’s promise before it resolves, but it MUST evict in a finally. If you apply the value-cache rule to a coalescing map you break coalescing outright: nobody can join a request that isn’t registered yet. If you skip the finally, you get my bug with extra steps, because a rejected in-flight entry that nobody deletes is a permanent negative answer.
My guard was the first kind wearing the second kind’s clothes.
The standard advice for the memoized-promise version, “don’t cache rejected promises,” which falls out of both the React cache() deep dive and React.lazy caching a rejected promise, does not cover my case. I never cached a promise. I never cached a value. The entire cache was one string, and a string cannot reject: nothing for a rejection-aware wrapper to notice, nothing to evict, no TTL to expire, no entry to inspect in a devtools panel. It was a negative cache with infinite TTL and zero bytes of payload.
Check it on your own system
There is no lint rule for this that I know of, so it is a grep plus a read. Find every assignment to your sentinel and look at what sits above it:
grep -rn -B 40 '<your-sentinel-name> *=' <your-source-dir>
For each hit, ask one question about the 40 lines printed above it: is there an await with a catch or an error branch between the guard and this write? If yes, you can reach that write without the work having succeeded, or reach a return without cleaning up. That’s the audit. It’s manual, and it’s about five minutes for a normal-sized module.
Then prove it with a dead port. Point your client at a port with nothing listening, trigger the operation once, then start the real backend and trigger the identical input twice more:
# 1. backend down. run your client action once.
# 2. bring the backend up and watch it receive requests:
tail -f <your-access-log> | grep <your-endpoint-path>
# no access log? put something noisy on the dead port instead and read stdout:
# python3 -m http.server <port> (or: nc -l <port>)
# 3. trigger the SAME input twice.
Passing looks like two more requests after the backend comes up. Failing looks like zero, forever, with a client that reports no error because the failure already happened and got remembered as an answer.
If your dedupe lives in Redis, don’t scan for the key: check what it’s holding. After the failed call:
redis-cli TTL dedupe:<key>
-1 means the key exists with no expiry. You have a negative cache with infinite TTL, which is exactly this bug. -2 (absent) is the healthy answer after a failure; a positive number means it will at least heal on its own.
The rule I would put on a wall: an entry in a cache is a claim that the work was done. If you write it before the work is done, you are not caching, you are lying to yourself in a durable format.