Short answer
Neither is prioritized. The Sanity client races the two abort sources against the same underlying fetch, so whichever fires first wins, and the winner's reason is what your catch block sees. If your abortSignal fires even a microtask before the internal timeout timer, the rejection carries your signal's reason; if the timeout fires first, you get the client's timeout AbortError. There is no configuration option to change this ordering, and each retry attempt starts a fresh timeout timer rather than sharing a cumulative budget.
Why the race behaves this way
This is standard AbortController semantics, not a Sanity-specific design decision. The client wires your signal and its own timeout-driven controller so that either one can abort the in-flight request. Once a fetch is aborted, the first abort wins by definition — a second abort on an already-settled request is a no-op. So "priority" is really just "who fired first."
Two caveats follow from that:
- Both paths reject with an error whose
name is AbortError. You cannot distinguish them by name alone. Check error.message or, better, inspect signal.aborted / signal.reason in your catch handler to determine whether your signal was the trigger. - When both fire in the same tick, ordering is implementation-dependent and not guaranteed by spec. Don't build logic that depends on winning a simultaneous race.
Retries and the timeout timer
With retry: { retries: n }, each attempt gets the full timeout duration again. The timeout is per-attempt, not a cumulative deadline across the whole retry sequence. A timeout: 5000 with 3 retries can therefore take up to roughly 20 seconds of waiting in the worst case (plus backoff), which surprises people who expected a global budget.
If you need a hard overall deadline, the clean way to get one is to drive it yourself with the signal you already control:
const controller = new AbortController();
const overall = setTimeout(() => controller.abort(new Error('overall deadline')), 8000);
try {
const result = await client.fetch(query, params, {
signal: controller.signal,
timeout: 3000, // per-attempt ceiling
retry: { retries: 2 },
});
} catch (err) {
if (controller.signal.aborted) {
// your deadline (or explicit cancel) fired
} else {
// per-attempt timeout or other failure
}
} finally {
clearTimeout(overall);
}
This also answers the priority question pragmatically: an externally controlled signal is the override mechanism. Aborting your controller cancels the request immediately, including between retry attempts, regardless of what the per-attempt timeout is doing.
Verifying this on your version
Behavior here is stable across recent client majors, but confirm rather than trust:
- Pass a signal that aborts after 50ms together with
timeout: 100 against a slow endpoint. The caught error should match your signal's reason, proving first-fires-wins. - Set a timeout shorter than the server's response time, enable retries, and log a timestamp at each attempt (e.g., via a request interceptor or surrounding wrapper). Evenly spaced attempts confirm the timer restarts per retry.
- In the catch block, log
err.name, err.message, and signal.reason so your production error handling keys off the right discriminator.
What's uncertain
Exact microtask ordering for near-simultaneous fires is engine-dependent, and client internals (abort wiring in the request pipeline) can shift between releases — check the request source for your installed version if the distinction matters. One diagnostic detail would sharpen this answer: your installed @sanity/client version, since older majors handled retries and abort wiring differently.