4e433158cd
---ci--- project: atelier phase: 3 milestone: v0.4 status: complete phase_role: execution phase_tag: v0.3.3 requirements: covered: [ATELIER-102, ATELIER-103, ATELIER-104, ATELIER-105] partial: [] ---/ci---
115 lines
6.5 KiB
Markdown
115 lines
6.5 KiB
Markdown
# TypeScript Async — Derived Application
|
|
|
|
> Applies Atelier's domain principles to TypeScript async specifically.
|
|
> Derives from `domains/` docs; introduces no new P-rules (D-063).
|
|
> See `languages/typescript.md` for the language first-principles stub.
|
|
|
|
## Promises and AbortSignal (Concurrency P7 Cancellation Support, C1 Correctness)
|
|
|
|
- **Every async function accepts an optional `AbortSignal`:** cancellation is a first-class parameter, not a side channel. The signal propagates to `fetch`, `setTimeout`, and downstream awaits.
|
|
- **`AbortController` is the producer side; `AbortSignal` is the consumer side:** a function takes a `signal` (read-only), the caller owns the `controller` and decides when to abort.
|
|
- **Abort propagates as a rejected `Promise`:** `fetch` rejects with `AbortError`; downstream code sees the rejection, not a silent no-op. This preserves `errors/P5` (recoverable when possible) — the caller can distinguish cancellation from a real failure.
|
|
- **Applies `concurrency/P7`:** no async operation runs without a path to cancel it. A long-running `await` with no signal is a hung request.
|
|
- **Never swallow `AbortError`:** re-throw or handle distinctly; cancellation is the caller's intent, not an error to log.
|
|
|
|
```typescript
|
|
async function fetchUser(id: UserId, signal?: AbortSignal): Promise<User> {
|
|
const ctrl = new AbortController();
|
|
signal?.addEventListener('abort', () => ctrl.abort());
|
|
const res = await fetch(`/users/${id}`, { signal: ctrl.signal });
|
|
if (!res.ok) throw new HttpError(res.status);
|
|
return res.json() as Promise<User>;
|
|
}
|
|
|
|
// caller controls cancellation
|
|
const ctrl = new AbortController();
|
|
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
try {
|
|
const u = await fetchUser(id, ctrl.signal);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
```
|
|
|
|
## async/await Discipline (Concurrency P8 Timeout Discipline, C2 Clarity)
|
|
|
|
- **`await` is the only async primitive in application code:** no `.then` chains, no callback pyramids. `async`/`await` reads top-to-bottom (Clarity C2).
|
|
- **Never `await` in a hot loop without batching:** sequential `await` in a `for` loop is O(n) latency. Use `Promise.all` for parallelism; `for await...of` only for genuine streams.
|
|
- **`Promise.race` for a timeout:** every external `await` has a deadline. `Promise.race([op, timeout])` rejects when the deadline passes.
|
|
- **`return` vs `return await`:** inside `try`/`finally`, `return await` runs the `finally`; bare `return` of a Promise defers the `finally` to the microtask. Prefer `return await` when cleanup must run.
|
|
- **Applies `concurrency/P8`:** a bare `await` with no timeout is an unbounded wait. External calls (network, disk) always race against a deadline.
|
|
|
|
```typescript
|
|
async function fetchWithTimeout(url: string, ms = 5000, signal?: AbortSignal): Promise<Response> {
|
|
const ctrl = new AbortController();
|
|
signal?.addEventListener('abort', () => ctrl.abort());
|
|
const timer = new Promise<never>((_, reject) =>
|
|
setTimeout(() => reject(new TimeoutError(ms)), ms)
|
|
);
|
|
try {
|
|
return await Promise.race([fetch(url, { signal: ctrl.signal }), timer]);
|
|
} finally {
|
|
clearTimeout(timer); // cleanup runs on success and on race-loss
|
|
}
|
|
}
|
|
```
|
|
|
|
## Error Handling in Async (Errors P5 Recoverable When Possible, Errors P1 Errors are Data)
|
|
|
|
- **Catch `unknown`, narrow with a type guard:** `catch (e: unknown)` — TS does not infer the error type. `instanceof` or a discriminator narrows it.
|
|
- **Retry with backoff for transient failures:** network blips are recoverable (Errors P5). Exponential backoff with jitter, capped retry count, and an `AbortSignal`-aware `setTimeout`.
|
|
- **No retry for non-idempotent operations:** a `POST` that creates a resource is not safely retryable without an idempotency key (applies `api/P6` Idempotency).
|
|
- **Typed errors over `Error` subclasses:** a discriminated union `AppError = Network | Timeout | Cancelled` carries context (Errors P4 Preserve Context) without `instanceof` chains.
|
|
|
|
```typescript
|
|
async function fetchRetry(url: string, attempts = 3, signal?: AbortSignal): Promise<Response> {
|
|
for (let i = 0; i < attempts; i++) {
|
|
try {
|
|
return await fetchWithTimeout(url, 5000, signal);
|
|
} catch (e: unknown) {
|
|
if (e instanceof AbortError) throw e; // do not retry cancellation
|
|
if (e instanceof TimeoutError && i < attempts - 1) {
|
|
await sleep(jitter(i), signal); // backoff before retry
|
|
continue;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
throw new Error('unreachable');
|
|
}
|
|
```
|
|
|
|
## Cancellation Propagation (Concurrency P7 Cancellation Support, Concurrency P9 Bounded Queues)
|
|
|
|
- **One signal, many consumers:** pass the same `AbortSignal` to every async call in a request. Aborting once cancels the whole tree.
|
|
- **Bounded concurrency with a semaphore:** a `Semaphore(N)` wrapping `Promise.all` caps in-flight requests (Concurrency P9 — bounded queues). Unbounded `Promise.all` on a 10k-item array exhausts file descriptors.
|
|
- **Cancellation is cooperative, not preemptive:** a long synchronous block inside an `async` function ignores the signal. Yield with `await Promise.resolve()` periodically in CPU-bound loops, or move to a worker.
|
|
- **Applies `messaging/delivery-semantics`:** a cancelable async operation is an at-most-once delivery — the caller may stop listening, the result may or may not arrive. Retry-on-cancel is at-least-once; the caller must declare which.
|
|
|
|
```typescript
|
|
async function mapBounded<T, U>(items: readonly T[], fn: (t: T, s: AbortSignal) => Promise<U>, limit = 8, signal?: AbortSignal): Promise<U[]> {
|
|
const ctrl = new AbortController();
|
|
signal?.addEventListener('abort', () => ctrl.abort());
|
|
const results: U[] = new Array(items.length);
|
|
let next = 0;
|
|
const workers = Array.from({ length: limit }, async () => {
|
|
while (true) {
|
|
const i = next++;
|
|
if (i >= items.length) break;
|
|
if (ctrl.signal.aborted) throw new AbortError();
|
|
results[i] = await fn(items[i], ctrl.signal);
|
|
}
|
|
});
|
|
await Promise.all(workers);
|
|
return results;
|
|
}
|
|
```
|
|
|
|
## Cross-References
|
|
|
|
- `domains/concurrency/patterns.md` — the cancellation/timeout/semaphore patterns applied here.
|
|
- `domains/concurrency/first-principles.md` — Concurrency P7 Cancellation Support, P8 Timeout Discipline, P9 Bounded Queues.
|
|
- `domains/messaging/delivery-semantics.md` — at-most-once vs at-least-once framing for async retry/cancel (IDEATE-40).
|
|
- `domains/errors/patterns.md` — typed async errors and retry-with-backoff.
|
|
- `languages/ts-types.md` — `Result<T, E>` and discriminated `AppError` used in async error handling.
|
|
- `languages/ts-tooling.md` — `no-floating-promises` lint rule that enforces these awaits. |