29ffb42898
---ci--- project: atelier phase: 0 milestone: v0.4 status: complete requirements: covered: [ATELIER-92, ATELIER-93, ATELIER-94, ATELIER-95, ATELIER-96, ATELIER-97, ATELIER-98, ATELIER-99, ATELIER-100, ATELIER-101, ATELIER-102, ATELIER-103, ATELIER-104, ATELIER-105, ATELIER-106, ATELIER-107, ATELIER-108, ATELIER-109, ATELIER-110, ATELIER-111, ATELIER-112, ATELIER-113, ATELIER-114, ATELIER-115, ATELIER-116, ATELIER-117] partial: [] ---/ci---
6.5 KiB
6.5 KiB
TypeScript Async — Derived Application
Applies Atelier's domain principles to TypeScript async specifically. Derives from
domains/docs; introduces no new P-rules (D-063). Seelanguages/typescript.mdfor 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 tofetch,setTimeout, and downstream awaits. AbortControlleris the producer side;AbortSignalis the consumer side: a function takes asignal(read-only), the caller owns thecontrollerand decides when to abort.- Abort propagates as a rejected
Promise:fetchrejects withAbortError; downstream code sees the rejection, not a silent no-op. This preserveserrors/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-runningawaitwith 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.
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)
awaitis the only async primitive in application code: no.thenchains, no callback pyramids.async/awaitreads top-to-bottom (Clarity C2).- Never
awaitin a hot loop without batching: sequentialawaitin aforloop is O(n) latency. UsePromise.allfor parallelism;for await...ofonly for genuine streams. Promise.racefor a timeout: every externalawaithas a deadline.Promise.race([op, timeout])rejects when the deadline passes.returnvsreturn await: insidetry/finally,return awaitruns thefinally; barereturnof a Promise defers thefinallyto the microtask. Preferreturn awaitwhen cleanup must run.- Applies
concurrency/P8: a bareawaitwith no timeout is an unbounded wait. External calls (network, disk) always race against a deadline.
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.instanceofor 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-awaresetTimeout. - No retry for non-idempotent operations: a
POSTthat creates a resource is not safely retryable without an idempotency key (appliesapi/P6Idempotency). - Typed errors over
Errorsubclasses: a discriminated unionAppError = Network | Timeout | Cancelledcarries context (Errors P4 Preserve Context) withoutinstanceofchains.
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
AbortSignalto every async call in a request. Aborting once cancels the whole tree. - Bounded concurrency with a semaphore: a
Semaphore(N)wrappingPromise.allcaps in-flight requests (Concurrency P9 — bounded queues). UnboundedPromise.allon a 10k-item array exhausts file descriptors. - Cancellation is cooperative, not preemptive: a long synchronous block inside an
asyncfunction ignores the signal. Yield withawait 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.
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 discriminatedAppErrorused in async error handling.languages/ts-tooling.md—no-floating-promiseslint rule that enforces these awaits.