alepha@docs:~/docs/guides/core$
cat 11-cancellation.md | pretty
8 min read
Last commit:

#Cancellation

Work that nobody is waiting for anymore should stop. A visitor closes the tab, an operator cancels a job, an MCP client gives up on a tool call, the app is asked to stop: the work behind each of them is still running, and so is every outbound call, query and child process it started.

Alepha gives every unit of work one AbortSignal and keeps it in the context. You never pass it down: the framework's own consumers read it, and so can your code.

typescript
 1import { $inject, Alepha } from "alepha"; 2  3class Exporter { 4  protected readonly alepha = $inject(Alepha); 5  6  async exportAll(ids: string[]): Promise<string[]> { 7    const rows: string[] = []; 8    for (const id of ids) { 9      // Throws the signal's reason once the request, job or call is gone.10      this.alepha.context.signal.throwIfAborted();11      rows.push(await this.render(id));12    }13    return rows;14  }15 16  protected async render(id: string): Promise<string> {17    return id;18  }19}

alepha.context.signal is never undefined. Outside any unit of work it is the app signal, which aborts when alepha.stop() begins.

#Where the signal comes from

Unit of work Its signal aborts when
An HTTP request the client disconnects before the response is complete (ServerRequest.signal)
A $job execution it is cancelled, or still running when stop's drain deadline passes, or times out
A queue message still being processed when the workers' drainTimeout passes at stop
A cron tick its task stops
An MCP tool call the client cancels it (notifications/cancelled), or its HTTP request disconnects
A $sse handler the stream ends: the client leaves, close() is called, or the handler finishes
A WebSocket admission the socket closes before the upgrade was decided
A CLI command the process receives SIGINT or SIGTERM

On Node the disconnect is the response's socket closing before the response finished. On Bun and Cloudflare Workers it is the runtime's own request.signal; on Workers that needs the enable_request_signal compatibility flag, which alepha build writes into wrangler.jsonc, also when the app supplies its own flag list.

The public signal parameters stay where they are ($job's handler, $sse's context, McpContext.signal, ServerRequest.signal), and each is the same object as alepha.context.signal inside that handler.

A unit of work does not abort when stop begins. The server, jobs and queue workers drain first, and abort what is still running at their own deadline.

#What inherits it

Without anyone passing a signal:

  • HttpClient (and so remote actions and links) passes it to fetch. A deduplicated GET shared by several callers is aborted only once its last caller has gone, so one visitor leaving never fails another's call.
  • ShellProvider kills its child process.
  • $proxy stops the upstream call.
  • The ORM refuses a read whose signal already fired, and cancels a running one where the driver can (postgres.js: Node and Hyperdrive). Bun.sql, PGlite, SQLite and D1 refuse before sending but let a started query finish.
  • SSR stops rendering a page for a visitor who left.

An explicit signal still wins, and composes with the ambient one: either can abort the call.

#Writes and transactions are never cut short

The ORM treats writes differently on purpose. Cutting a sequence of writes in half leaves work that nothing undoes: a payment captured with no order.

  • A read outside a transaction inherits the signal.
  • A write outside a transaction does not. It still honors a signal you pass explicitly.
  • A transaction checks the signal before BEGIN only. Once begun it runs to commit, or rolls back on its own error, whatever happens to its caller.
typescript
1const rows = await users.findMany({ where: { active: { eq: true } } });2// Explicit: refused before it is sent if `signal` already fired.3await users.create({ name: "Ada" }, { signal });

#Narrowing it: withSignal

withSignal(signal, fn) runs fn under a signal that aborts when either the current one or signal does. It can only narrow, never widen: the work still stops when its request does.

typescript
 1import { $inject, Alepha } from "alepha"; 2import { HttpClient } from "alepha/server"; 3  4class Quotes { 5  protected readonly alepha = $inject(Alepha); 6  protected readonly http = $inject(HttpClient); 7  8  latest() { 9    // Five seconds at most, and never past the caller's own signal.10    return this.alepha.context.withSignal(AbortSignal.timeout(5_000), () =>11      this.http.fetch("https://quotes.example.com/latest"),12    );13  }14}

#Opting out: detachSignal

detachSignal(fn) runs fn under the app signal instead of the current one: for work that must finish after the client left, such as notifying a webhook once an order is placed.

typescript
 1import { $inject, Alepha } from "alepha"; 2import { HttpClient } from "alepha/server"; 3  4class OrderEvents { 5  protected readonly alepha = $inject(Alepha); 6  protected readonly http = $inject(HttpClient); 7  8  placed(orderId: string) { 9    // Survives the visitor closing the tab.10    void this.alepha.context.detachSignal(() =>11      this.http.fetch("https://hooks.example.com/orders", {12        method: "POST",13        body: JSON.stringify({ orderId }),14      }),15    );16  }17}

Detached work still dies at app stop: the app signal is what it runs under. Work that must survive a shutdown, or a crash, belongs in a $job, which is recorded before it runs and retried after.

#An abort is not an error

Work cut short because nobody is listening is an outcome, not a failure, and the framework never reports it as one:

  • A request aborted by its client skips server:request:error and the response hooks, writes nothing to the dead socket, and logs one debug line with the status it would have had, 499.
  • A job execution aborted by a cancel or the drain is recorded cancelled, never error, and is not retried.
  • An aborted outbound call or child process is logged at debug.

Classify an error the same way in your own code with isAbortError:

typescript
 1import { $inject, Alepha } from "alepha"; 2  3class Sync { 4  protected readonly alepha = $inject(Alepha); 5  6  async run(step: () => Promise<void>): Promise<"done" | "stopped"> { 7    try { 8      await step(); 9      return "done";10    } catch (error) {11      if (this.alepha.context.isAbortError(error)) {12        return "stopped";13      }14      throw error;15    }16  }17}

It answers true for the signal's own reason once it aborted, for an AbortedError (the reason every framework abort carries, naming who aborted), and for a DOMException named AbortError, also when wrapped as a cause. A real failure that happens after an unrelated abort reports normally.

#Timeouts

A timeout is an abort with a reason: the work did not finish in the time it was given. The work's signal aborts with a TimeoutError (status 503), so everything downstream stops as it would on a disconnect, and the caller is answered at the deadline, even when the code ignores its signal.

Where Option Default
$action timeout none; serverApiOptions.timeout (ms) sets one
$tool timeout none; mcpToolOptions.timeout (ms) sets one
HttpClient.fetch timeout 30 s on the server (httpClientOptions.timeout)
A repository statement timeout DATABASE_TIMEOUT: 5 s on serverless, else off
$job timeout none

Every one takes a DurationLike (5000, [30, "seconds"]), and false opts out of a default. The option on the call wins over the default.

typescript
 1import { $inject } from "alepha"; 2import { $action, HttpClient } from "alepha/server"; 3  4class Reports { 5  protected readonly http = $inject(HttpClient); 6  7  build = $action({ 8    method: "POST", 9    path: "/reports",10    // The middlewares and the handler, at most 10 seconds.11    timeout: [10, "seconds"],12    handler: async () => {13      // A slow third party gets 5 seconds of those.14      const { data } = await this.http.fetch("https://rates.example.com", {15        timeout: [5, "seconds"],16      });17      return JSON.stringify(data);18    },19  });20 21  exportAll = $action({22    method: "POST",23    path: "/exports",24    // Legitimately long: out of an app-wide default.25    timeout: false,26    handler: async () => "started",27  });28}

#What the caller receives

  • An HTTP client of a timed-out action receives a 503 named TIMEOUT, { "name": "TIMEOUT", "message": "Timed out after 10 s", "data": { "status": 503, "timeout": 10000 } }. A query that timed out answers 503 too, as DbTimeoutError, so a client sees one status for "did not answer in time, retry". OpenAPI documents the 503 on every action with a timeout, and strict mode (errors) lets it through.
  • A model calling a timed-out $tool receives a Tool Execution Error (isError: true, "Error: Timed out after 30 s"), not a protocol error, so it can retry or narrow its request.
  • A job run that timed out is a failure recorded with its TimeoutError, retried like any other, never cancelled.
  • Code calling fetch or a repository gets the TimeoutError (or the DbTimeoutError carrying it as cause).

A timeout is a failure, not an outcome: isAbortError answers false for a TimeoutError, and it is logged and reported like any other error. Tell the two apart with signal.reason instanceof TimeoutError.

#The outbound default

A server-side HttpClient.fetch has a 30 second timeout unless the call or the httpClientOptions atom says otherwise. It covers the headers and the body. It does not apply to fetchAction (a call to an action of the app, through $remote or a link, which its own server bounds) nor in the browser, where cutting a long action client-side would leave the server working on it.

typescript
1import { Alepha } from "alepha";2import { httpClientOptions } from "alepha/server";3 4const alepha = Alepha.create();5// Every server-side fetch: one minute. `false` turns the default off.6alepha.store.mut(httpClientOptions, () => ({ timeout: 60_000 }));

#Queries

DATABASE_TIMEOUT (milliseconds, 0 disables) is the default timeout of every repository statement, on every driver, and DATABASE_TIMEOUT_SCOPE=reads limits it to reads. A statement inside a transaction has none: a transaction is never cut short. A write's timeout never makes it inherit the request's signal: a write still runs to the end when its client leaves.

typescript
1const rows = await users.findMany(2  { where: { active: { eq: true } } },3  { timeout: [2, "seconds"] },4);

What a timeout stops depends on the driver:

Driver A timed-out statement
PostgreSQL (postgres.js) is cancelled on the server
Cloudflare D1 is abandoned: D1 cannot abort, it finishes on its own
SQLite (Node, Bun) runs to completion if it started: SQLite is synchronous
PGlite, Bun.sql runs to completion if it started; refused if not yet sent

Except under SQLite, whose running statement holds the whole process, the caller is released at the deadline. Postgres statement_timeout is not used: the client-side cancel already stops the statement, without a round trip per query.

#In the browser

There is no async context in the browser. alepha.context.signal is the app signal, and withSignal and detachSignal simply call their function. Cancellation there stays explicit, where it already lives: useAction, useQuery and the query cache abort what they started.