Why
tryCancel() is a synchronous signal.aborted read — it cannot interrupt a pending await. So on a handler timeout, await fetch(url) (or sendRequest, or a DB call) runs to completion and is then discarded at the next tryCancel(). The signal is the only way to actually abort in-flight foreign work.
The plumbing to consume it already exists end to end:
SendRequestOptions.signal → send-request.ts:38 → AbortSignal.any merge at base-http-client.ts:121 → fetch(…, { signal }) at :181 → impit.fetch(request, { signal }) in impit-client:105
HttpCrawler already relies on it for its own navigation: storage.getStore()?.cancelTask.signal — http-crawler.ts:882
@apify/timeout aborts before it rejects (fire = () => { settled = true; context.cancelTask.abort(); reject(error) }), so the in-flight request rejects, that unwinds the handler, and the already-settled outer promise still reports our TimeoutError
What's missing is only that this is unreachable from supported API. Today it costs an undocumented import and an AsyncLocalStorage poke:
import { storage } from '@apify/timeout';
requestHandler: async ({ sendRequest }) => {
const signal = storage.getStore()?.cancelTask.signal;
await sendRequest({ url: '…' }, { signal });
}
We also already ship the other half of this surface publicly — context.extendTimeout(secs) (crawler_commons.ts:253) pushes the deadline, but nothing lets you observe it firing.
Ask
- add
context.signal: AbortSignal
- pick its semantics first:
context.cancelTask = parent?.cancelTask ?? new AbortController() means non-nested frames own separate controllers, so navigation (http-crawler.ts:462,522,579) and the handler (basic-crawler.ts:2100) have different ones, while raceWithTimeout — the whole-request timeout — has none at all, deliberately (request-timeout.ts:46-48). Inside the handler the answer is unambiguous and useful; elsewhere a naive getter returns a different object per phase, or undefined.
Non-goals
- replacing
tryCancel(). It stays the right tool for our own chokepoints (mostly checking on storage access)
@apify/timeoutwholesale) asnot planned; this is the small oneAbortSignaloption to theBaseHttpClientinterface #3456 (AbortSignalinSendRequestOptions)Why
tryCancel()is a synchronoussignal.abortedread — it cannot interrupt a pendingawait. So on a handler timeout,await fetch(url)(orsendRequest, or a DB call) runs to completion and is then discarded at the nexttryCancel(). The signal is the only way to actually abort in-flight foreign work.The plumbing to consume it already exists end to end:
SendRequestOptions.signal→send-request.ts:38→AbortSignal.anymerge atbase-http-client.ts:121→fetch(…, { signal })at:181→impit.fetch(request, { signal })inimpit-client:105HttpCrawleralready relies on it for its own navigation:storage.getStore()?.cancelTask.signal—http-crawler.ts:882@apify/timeoutaborts before it rejects (fire = () => { settled = true; context.cancelTask.abort(); reject(error) }), so the in-flight request rejects, that unwinds the handler, and the already-settled outer promise still reports ourTimeoutErrorWhat's missing is only that this is unreachable from supported API. Today it costs an undocumented import and an
AsyncLocalStoragepoke:We also already ship the other half of this surface publicly —
context.extendTimeout(secs)(crawler_commons.ts:253) pushes the deadline, but nothing lets you observe it firing.Ask
context.signal: AbortSignalcontext.cancelTask = parent?.cancelTask ?? new AbortController()means non-nested frames own separate controllers, so navigation (http-crawler.ts:462,522,579) and the handler (basic-crawler.ts:2100) have different ones, whileraceWithTimeout— the whole-request timeout — has none at all, deliberately (request-timeout.ts:46-48). Inside the handler the answer is unambiguous and useful; elsewhere a naive getter returns a different object per phase, orundefined.Non-goals
tryCancel(). It stays the right tool for our own chokepoints (mostly checking on storage access)