Skip to content

step-errors

@alexkroman1/aai/step-errors — the failure a step should throw, and the helpers that throw it.

A FACADE. The subpath resolves here rather than at step-errors.ts, which buys two things the direct form could not. That module can be SPLIT as it grows without moving the published entry point — the path an implementation file happens to have is not a thing to promise anyone — and a name it gains next reaches the public surface only when a line is added below, rather than the moment it is written.

Named re-exports rather than export * for the second half of that: the wildcard form re-exports whatever arrives, and needs a noReExportAll suppression the escape-hatch ratchet only lets move down.

fatalOn<T>(isMatch, fn): T

Run fn, and make the failure isMatch recognises FATAL.

For the call that throws its own class with no verdict on it — a parser refusing a file (parseWav’s UnsupportedRecordingError), a plan that found a recording too short to cut — where toStepError rightly declines to guess and so the engine retries a decision that is a pure function of the input. Three more attempts over the same bytes reach the same throw; this says so at the call rather than in a catch block the author has to shape by hand (see throwFatalStepError for why that block is awkward to write).

Only the matching failure is converted. Everything else passes through UNCHANGED, which is the half that keeps this narrow: a TypeError from a bug stays plainly retryable, and a failure that already carries a verdict (a TranscribeError) keeps it. That is the opposite default from throwFfmpegStepError, which stops on everything it does not recognise — ffmpeg having READ the file is the evidence there; here the only evidence is the class the caller named.

Answers in kind: a sync fn stays sync, an async one is awaited and its rejection classified the same way. orFatal is the same call with the match spelled as a class.

T

(err) => boolean

Whether a thrown value is the terminal failure. Called with whatever was thrown, so it may read a name or a code as well as test a class.

() => T

The call. Pure in the sense that matters: the same input reaches the same verdict, so no attempt changes it.

T

import { parseWav, type WavFormat } from "@alexkroman1/aai/step";
import { fatalOn } from "@alexkroman1/aai/step-errors";
export function header(bytes: Uint8Array): WavFormat {
return fatalOn((err) => err instanceof Error && err.name === "UnsupportedRecordingError", () =>
parseWav(bytes, bytes.byteLength),
);
}

orFatal<T>(fn, options): T

fatalOn with the match spelled as a CLASS: run fn, and make an instanceof when failure fatal.

The common spelling, because the terminal failure is almost always a class the called module exports. One thing to know about instanceof here: a guest bundle can hold two copies of a module (step-error-classes.ts says why its own membership test is a brand instead), so a class the STEP threw through a different copy than the one when names would not match and would stay retryable. The classes this is written for — a parser’s refusal thrown and caught in the same bundle — are not that case; one that might be takes fatalOn with a name check.

T

() => T

OrFatalOptions

T

import { parseWav, UnsupportedRecordingError, type WavFormat } from "@alexkroman1/aai/step";
import { orFatal } from "@alexkroman1/aai/step-errors";
export function header(bytes: Uint8Array): WavFormat {
// A WAV the cutter cannot read is a WAV it cannot read on the next attempt.
return orFatal(() => parseWav(bytes, bytes.byteLength), { when: UnsupportedRecordingError });
}

throwFatalStepError(cause, message?): never

Stop the engine retrying: throw a FatalError whatever the cause was.

For the failure a step has DECIDED is terminal on grounds no status code carries — a missing API key, a recording in a format the step cannot cut. Three more attempts find the same gap, and spending them turns an immediate failure into one that arrives a minute later saying the same thing.

Separate from toStepError precisely because that one refuses to guess: “I could not classify this” and “I classified this as terminal” are different claims, and collapsing them would make every unclassified failure silently unretryable.

unknown

string

never

import { requireStepEnv } from "@alexkroman1/aai/step";
import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
export function apiKey(): string {
try {
return requireStepEnv("ASSEMBLYAI_API_KEY");
} catch (err) {
return throwFatalStepError(err);
}
}

throwFfmpegStepError(cause, message?): never

The verdict a failed ffmpeg run deserves: retry a timeout or an aborted, stop on everything else.

FfmpegError.kind (@alexkroman1/aai/ffmpeg) is what makes this decidable. An exit is ffmpeg having READ the file and refused it, so every retry re-reads the same bytes and reaches the same conclusion while burning the budget a real transient needs; a missing-binary is aai dev on a laptop with no ffmpeg, already carrying its install instructions; an output-too-large is a cap only the caller can raise. A timeout or an aborted is worth another attempt.

Everything it does not recognise is FATAL — the opposite of toStepError’s default — and that inversion is why this is its own export. toStepError refuses to invent a verdict, so an unclassified cause passes through retryable; here the caller has already decided, this step having run one subprocess over one file. Folding the two together would silently disable retries for every unclassified failure in the SDK, so the fatal/retryable choice stays visible in the name the author types.

The retryable arm goes through throwStepError even though it classifies nothing. An FfmpegError is neither a Response nor an SDK error carrying retryable, so it is rethrown UNCHANGED and the engine’s unclassified default retries it — where constructing a RetryableError would replace ffmpeg’s own message and its argv with a sentence, and the argv is what you paste into a shell.

The failure is recognised STRUCTURALLY rather than with instanceof, and that is forced. FfmpegError types its signal as NodeJS.Signals, and this module compiles under sdk/tsconfig.json, which sets types: [] — so no module reachable from here may name a Node type, let alone import node:child_process. That budget is the whole reason this subpath can be named from a workflows/ module: that bundle keeps everything a module holds at MODULE scope, so one surviving reference to @alexkroman1/aai/ffmpeg puts a child-process spawn inside a node:vm with no require, and every run dies at replay with ReferenceError: require is not defined. Two templates each carried a whole one-function FILE to keep that reference on the far side of a boundary only a step body crosses; owning the decision here retires both.

unknown

What the ffmpeg call threw. Anything at all — see above.

string

The sentence to report. Defaults to the cause’s own, which for an FfmpegError is ffmpeg’s log tail.

never

import { transcodeToWav } from "@alexkroman1/aai/ffmpeg";
import { throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
export async function toPcm(bytes: Uint8Array): Promise<Uint8Array> {
return await transcodeToWav(bytes, { sampleRate: 16_000 }).catch(throwFfmpegStepError);
}

throwStepError(cause, message?): never

toStepError, thrown.

The form a .catch() takes, which is the shape both LLM templates want: stepGenerate rejects with a StepGenerateError and the step wants that classified before it reaches the engine.

It is a function taking the cause as an ARGUMENT rather than a throw inside a catch block, and that is mechanical rather than stylistic: what Biome’s useErrorCause asks of an error constructed inside a catch is that it carry the one being handled, and a call site cannot forget to do that here — the cause is the first parameter, and both of these attach it. Nothing is being swallowed either way: the original is what was passed in.

unknown

string

never

import { stepGenerate } from "@alexkroman1/aai/step";
import { throwStepError } from "@alexkroman1/aai/step-errors";
export async function summarize(text: string): Promise<string> {
return await stepGenerate(text, { system: "Summarize in two sentences." }).catch(
throwStepError,
);
}

toStepError(cause, message?): Error

The step error one failure deserves.

cause decides how the verdict is reached, and the three cases are the three ways a step learns it failed:

  • A Response — a non-2xx from an API the step called. Transient by isTransientStatus (/step), with the delay from its Retry-After when it named one.
  • A ChannelDeliveryError (@alexkroman1/aai/channels) — a platform that refused a post, having already reached the same verdict. A 4xx from a webhook is terminal by construction: a revoked webhook and a wrong variable name answer identically on every attempt.
  • A StepGenerateError or a TranscribeError (both /step) — the LLM gateway and the transcription endpoints, each of which has already made the same judgement and recorded it on retryable/retryAfter. A transcription refusal the PROVIDER decided — a failed job, a recording with no speech in it — arrives with retryable: false, which is the whole reason it is carried rather than re-derived from a status that is not there.
  • Anything else — a verdict this function cannot reach, so it does not invent one: the value is returned unchanged if it is an Error and wrapped in a plain Error if it is not. Both are retryable by the engine’s default, which is the safe direction — the alternative is silently disabling retries for a failure nobody classified. Reach for throwFatalStepError where the step really has decided a failure is terminal.

unknown

What failed.

string

The sentence to report. Defaults to the response’s status line, or the cause’s own message.

Error

import { toStepError } from "@alexkroman1/aai/step-errors";
export async function fetchOrder(id: string): Promise<unknown> {
const response = await fetch(`https://api.example.com/orders/${id}`);
if (!response.ok) throw toStepError(response, `Order ${id}: HTTP ${response.status}`);
return await response.json();
}

A failure that another attempt cannot fix.

Throwing one fails the RUN, not merely the step — a step whose remaining attempts are pointless has nothing left to contribute. Reach for it where the far side has already given a terminal answer: a 404 on a resource that was deleted, a 422 on input that will be malformed on every attempt, a provider saying the recording has no speech in it.

  • Error

new FatalError(message, options?): FatalError

string

unknown

FatalError

Error.constructor

static is(value): value is FatalError

Is value a FatalError, including one from another copy of this module?

unknown

value is FatalError

readonly fatal: true = true

Always true.

A readable field rather than only the brand, because it is what shows up in a journaled failure and in a log line — fatal: true in a run’s history answers “why did this stop after one attempt” without the reader knowing this class exists.


A failure another attempt might survive, with an optional “not before”.

  • Error

new RetryableError(message, options?): RetryableError

string

RetryableErrorOptions

RetryableError

Error.constructor

static is(value): value is RetryableError

Is value a RetryableError, including one from another copy of this module?

unknown

value is RetryableError

readonly retryAfter: Date

When the next attempt may run. Always a Date — a number passed to the constructor is resolved against the clock AT CONSTRUCTION, which is the moment the caller meant.

readonly optional retryAfterNamed?: boolean

Whether the thrower NAMED the delay, or retryAfter is the DEFAULT_RETRY_DELAY_MS fill.

The field StepOptions.maxWaits reads: only a delay the far side named (a Retry-After on a 429, a rate-limited gateway’s own “come back at”) is a WAIT out of that budget. The default is a second ahead of the clock at construction and so would read as “still in the future” on every catch, which made every unclassified transient failure a free retry under maxWaits. A clock test cannot tell the two apart; this can.

Optional in the TYPE, because of the two-copies case the brand exists for (the module doc): an instance from an older copy of this class carries no such field, and a reader treats its absence as “not named” — the direction that spends an attempt. Every instance this constructor makes sets it.

ErrorClass = (…args) => Error

An error CLASS, as orFatal takes one: anything new-able that makes an Error, abstract or not.

…never[]

Error


OrFatalOptions = object

Input

Options for orFatal.

when: ErrorClass

The error class whose instances are terminal. Anything else is rethrown unchanged.


RetryableErrorOptions = object

Input

What RetryableError accepts for its delay.

optional cause?: unknown

optional retryAfter?: number | Date

When the next attempt may run: a delay in MILLISECONDS, or the absolute Date the far side named.

Defaults to DEFAULT_RETRY_DELAY_MS from now. The DevKit accepted a duration STRING here too ("5s") and this does not — a string delay is one more parser to own and no call site in the repo passed one, every one of them having a Retry-After header or nothing.

const DEFAULT_RETRY_DELAY_MS: number

How long a RetryableError that names no delay waits.

One second, which is what the DevKit’s class defaulted to — kept so the migration changes no timing it does not have to. It is not a considered number, and a caller who has the far side’s own Retry-After should pass it: this SDK encourages fan-out, so N segments meet a rate limit together and a second later all N ask again.

Re-exports orFail