This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.
Every codebase has that one bug that looks tiny on paper and behaves like a small landmine underneath. This is the story of one of those bugs, and the fix I shipped for it in OpenClaw, a large open source personal AI assistant project.
OpenClaw
is a cross platform personal AI assistant written mostly in TypeScript. It runs as a gateway process that connects large language models to real communication channels such as Telegram, Slack, Discord, and Apple Messages, while also handling sessions, memory, plugins, and tool calls for agents working on your behalf.
Because the gateway is the thing sitting between a user's message and a model's response, it lives and dies by how well it manages network requests. Every outbound call, whether it is talking to a model provider, a channel API, or an internal service, gets wrapped with a shared timeout utility so nothing hangs the event loop forever. That utility is called fetchWithTimeout
, and it is used across dozens of call sites in the codebase.
I am aniruddhaadak80
on GitHub, and this project is one of the places where I have been sending small, focused fixes rather than one giant rewrite. You can see the full list of my open source work on my GitHub profile.
Here is the problem in plain words.
fetchWithTimeout
accepts a normal RequestInit
object as one of its parameters, the same shape you would pass to the native fetch
function. A caller can put anything in there, including their own AbortSignal
, if they already have a reason to cancel the request early. Maybe the user pressed stop. Maybe the session ended. Maybe a parent operation was cancelled and everything underneath it should stop too.
The trouble was in how the utility built its own internal signal for the timeout. Internally it spread the caller's init
object first, and then added its own timeout signal on top of it. That ordering meant the caller's signal was silently thrown away every single time. Whatever cancellation logic the calling code thought it had wired up simply did not exist anymore once the request reached fetchWithTimeout
.
The practical effect was not a crash. It was quieter and arguably worse. A request that the rest of the system believed had been cancelled would keep running in the background until either it finished naturally or the internal timeout eventually caught up with it. Under normal load you would not notice. Under real load, with many concurrent sessions and channels, this is exactly the kind of leak that piles up, ties up connections, and makes the gateway feel sluggish for reasons that are hard to trace back to a root cause.
Here is the pull request with the actual change.
In src/utils/fetch-timeout.ts
, the fetchWithTimeout
wrapper is implemented as follows:
export async function fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs: number,
fetchFn: typeof fetch = fetch,
): Promise<Response> {
const { signal, cleanup } = buildTimeoutAbortSignal({
timeoutMs: Math.max(1, timeoutMs),
operation: "fetchWithTimeout",
url,
});
try {
return await fetchFn(url, { ...init, signal });
} finally {
cleanup();
}
}
However, if the caller specifies a custom AbortSignal
in init.signal
(for instance, to cancel the request if a parent operation is aborted or the client disconnects), this signal is completely overridden by the new signal created in buildTimeoutAbortSignal
.
The caller-provided AbortSignal
is silently dropped and ignored, meaning that cancellations from the caller side will not abort the request during fetchWithTimeout
.
Pass init.signal
to buildTimeoutAbortSignal
so that the timeout controller is chained to the parent signal:
const { signal, cleanup } = buildTimeoutAbortSignal({
timeoutMs: Math.max(1, timeoutMs),
operation: "fetchWithTimeout",
url,
signal: init.signal,
});
To show what the core problem looked like, here is a simplified, rewritten version of the pattern that was causing the issue. This is not a line for line copy of the original file, it is a clean illustration of the same bug shape.
Before the fix, the caller's signal never survives:
async function fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs: number,
fetchFn: typeof fetch = fetch,
) {
const { signal, cleanup } = buildTimeoutAbortSignal(timeoutMs)
try {
// init.signal, if the caller set one, gets overwritten right here
return await fetchFn(url, { ...init, signal })
} finally {
cleanup()
}
}
After the fix, both signals get to matter:
async function fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs: number,
fetchFn: typeof fetch = fetch,
) {
const { signal: timeoutSignal, cleanup } = buildTimeoutAbortSignal(timeoutMs)
const signal = init.signal
? AbortSignal.any([init.signal, timeoutSignal])
: timeoutSignal
try {
return await fetchFn(url, { ...init, signal })
} finally {
cleanup()
}
}
The change is small on the surface, but it changes the actual behavior of every call site that ever passes its own signal into this helper.
My first instinct when I read the code was to just fix the object spread order. That would have worked in the narrow sense, but it would have meant the caller's signal wins and the internal timeout signal gets dropped instead, which just moves the bug from one side to the other.
What I actually wanted was a helper that respects both reasons to cancel a request at the same time. AbortSignal.any()
is built exactly for this. It takes an array of signals and returns a new signal that fires the moment any one of them fires. So now:
✅ If the caller cancels early, the request stops early, exactly as intended.
✅ If the caller never sets a signal, the timeout still works exactly as it always did.
✅ If both would eventually fire, whichever one fires first wins, and cleanup still runs through the existing finally
block.
I kept the function signature untouched on purpose. Every existing call site across the codebase continues to work without any changes, since the fix lives entirely inside the helper. That mattered a lot in a project this size, where dozens of files call into this one function and nobody wants a fix that turns into a fifty file refactor.
I also went back through related open issues in the repository describing symptoms like stalled polling loops and requests that outlived their expected lifetime, and confirmed this fix addresses the same underlying pattern rather than just one isolated call site.
Clear the Lineup
asks for one definitive fix, so the AbortSignal fix above is my main entry. For context on the kind of work I generally send upstream, here is a short table of other pull requests of mine that are currently merged into their respective projects.
| Project | Pull Request | What I Fixed |
|---|---|---|
openclaw/openclaw |
||
openclaw/openclaw
topoteretes/cognee
OS Error 3
) in the LanceDB integrationtopoteretes/cognee
topoteretes/cognee
NousResearch/hermes-agent
None
response from request_permission
instead of letting it fail silentlyTracer-Cloud/opensre
Tracer-Cloud/opensre
eks_*
keys so the health checker actually evaluates EKS evidenceEvery one of these is a live, merged pull request, no drafts and nothing that was closed without merging. You can see the complete history on my GitHub profile, along with my other projects like Folio-Motion and SkillSphere.
Bugs like this one do not announce themselves. Nothing throws, nothing logs an error, the request just quietly keeps running when everyone assumed it had stopped. Finding it meant reading the helper function carefully enough to notice that object spread order was doing something nobody intended, and fixing it meant choosing a solution that respects every caller's expectations instead of just patching the one symptom I happened to see.
If you want to see more of what I build and fix, I am aniruddhaadak80
on GitHub, and you can find my portfolio at aniruddha-adak.vercel.app, my writing on Dev.to and Medium, and my day to day thoughts on X.
Small fixes, applied to the right place, are still real engineering. You do not need a rewrite to make a system more honest about what it promises to do.
Thanks for reading, and good luck smashing your own bugs this challenge.