The Fetch That 500s With No Log Line
AbortSignal.timeout looked like the clean way to bound an upstream call. On Vercel, inside Next's patched fetch, it produced a 500 and nothing in the logs. Three routes now use a manual AbortController.
A route that returns 500 is a normal bad day. A route that returns 500 and writes nothing to the function log is a different thing, because every tool you would use to find the cause depends on the log line existing.
That was /api/iss for a while. The route calls wheretheiss.at for the live position and falls back to a second source. Locally it was perfect. On Vercel it would intermittently answer 500, and the runtime logs for that invocation were empty. Not an error, not a stack, nothing.
The only unusual thing in the code was the timeout. I had written fetch(url, { signal: AbortSignal.timeout(ms) }), which is the standard-library way to say give up after a few seconds. It is the right call in Node. It is not the right call inside a Next.js route handler on Vercel, because the fetch in there is not Node's fetch. Next patches it to add caching and request deduplication, and whatever that wrapper does with a signal that was created by the static helper, the result on the platform was an invocation that died before my code could log.
I never got a clean root cause out of it, and I stopped trying once the workaround was obviously correct. The workaround is a manual AbortController: create it, setTimeout to abort it, pass controller.signal, and clearTimeout in a finally. Same behaviour, same budget, no mystery. The ISS route got fetchWithBudget. The weather server helper got the same shape, with the one extra that it still passes next.revalidate so the forecast stays in the data cache. The status scanner got a timed() wrapper that goes one step further and reads the body inside the timed section, so a server that answers the headers and then stalls the stream is also cut off.
Two details that mattered. The timer has to be cleared on success, or a function that returns quickly still holds an open handle until the timeout fires, which on a serverless platform is a good way to keep an invocation alive for nothing. And the budget has to be real: the ISS trail endpoint measured over six seconds on its own, so its budget is 12, and the route keeps a 30-minute stale copy to serve during rate-limit spells rather than fail.
Each of the three files carries a comment saying why it does this by hand, because the next person to read it, probably me, will see the manual controller and want to tidy it into AbortSignal.timeout. That tidy would reintroduce a 500 that leaves no evidence.
The lesson: a 500 with no log is not a bug in your handler. It is something between the platform and your handler, and the list of things that live there is short. Patched globals are at the top of it.