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.
Functions
Section titled “Functions”fatalOn()
Section titled “fatalOn()”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.
Type Parameters
Section titled “Type Parameters”T
Parameters
Section titled “Parameters”isMatch
Section titled “isMatch”(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.
Returns
Section titled “Returns”T
Example
Section titled “Example”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()
Section titled “orFatal()”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.
Type Parameters
Section titled “Type Parameters”T
Parameters
Section titled “Parameters”() => T
options
Section titled “options”Returns
Section titled “Returns”T
Example
Section titled “Example”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()
Section titled “throwFatalStepError()”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.
Parameters
Section titled “Parameters”unknown
message?
Section titled “message?”string
Returns
Section titled “Returns”never
Example
Section titled “Example”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()
Section titled “throwFfmpegStepError()”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.
Parameters
Section titled “Parameters”unknown
What the ffmpeg call threw. Anything at all — see above.
message?
Section titled “message?”string
The sentence to report. Defaults to the cause’s own, which
for an FfmpegError is ffmpeg’s log tail.
Returns
Section titled “Returns”never
Example
Section titled “Example”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()
Section titled “throwStepError()”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.
Parameters
Section titled “Parameters”unknown
message?
Section titled “message?”string
Returns
Section titled “Returns”never
Example
Section titled “Example”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()
Section titled “toStepError()”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 byisTransientStatus(/step), with the delay from itsRetry-Afterwhen 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
StepGenerateErroror aTranscribeError(both/step) — the LLM gateway and the transcription endpoints, each of which has already made the same judgement and recorded it onretryable/retryAfter. A transcription refusal the PROVIDER decided — a failed job, a recording with no speech in it — arrives withretryable: 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
Errorand wrapped in a plainErrorif 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.
Parameters
Section titled “Parameters”unknown
What failed.
message?
Section titled “message?”string
The sentence to report. Defaults to the response’s status line, or the cause’s own message.
Returns
Section titled “Returns”Error
Example
Section titled “Example”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();}Classes
Section titled “Classes”FatalError
Section titled “FatalError”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.
Extends
Section titled “Extends”Error
Constructors
Section titled “Constructors”Constructor
Section titled “Constructor”new FatalError(
message,options?):FatalError
Parameters
Section titled “Parameters”message
Section titled “message”string
options?
Section titled “options?”cause?
Section titled “cause?”unknown
Returns
Section titled “Returns”Overrides
Section titled “Overrides”Error.constructor
Methods
Section titled “Methods”
staticis(value):value is FatalError
Is value a FatalError, including one from another copy of this module?
Parameters
Section titled “Parameters”unknown
Returns
Section titled “Returns”value is FatalError
Properties
Section titled “Properties”
readonlyfatal: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.
RetryableError
Section titled “RetryableError”A failure another attempt might survive, with an optional “not before”.
Extends
Section titled “Extends”Error
Extended by
Section titled “Extended by”Constructors
Section titled “Constructors”Constructor
Section titled “Constructor”new RetryableError(
message,options?):RetryableError
Parameters
Section titled “Parameters”message
Section titled “message”string
options?
Section titled “options?”Returns
Section titled “Returns”Overrides
Section titled “Overrides”Error.constructor
Methods
Section titled “Methods”
staticis(value):value is RetryableError
Is value a RetryableError, including one from another copy of this module?
Parameters
Section titled “Parameters”unknown
Returns
Section titled “Returns”value is RetryableError
Properties
Section titled “Properties”retryAfter
Section titled “retryAfter”
readonlyretryAfter: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.
retryAfterNamed?
Section titled “retryAfterNamed?”
readonlyoptionalretryAfterNamed?: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.
Type Aliases
Section titled “Type Aliases”ErrorClass
Section titled “ErrorClass”ErrorClass = (…
args) =>Error
An error CLASS, as orFatal takes one: anything new-able that makes
an Error, abstract or not.
Parameters
Section titled “Parameters”…never[]
Returns
Section titled “Returns”Error
OrFatalOptions
Section titled “OrFatalOptions”OrFatalOptions =
object
Input
Options for orFatal.
Properties
Section titled “Properties”when:
ErrorClass
The error class whose instances are terminal. Anything else is rethrown unchanged.
RetryableErrorOptions
Section titled “RetryableErrorOptions”RetryableErrorOptions =
object
Input
What RetryableError accepts for its delay.
Properties
Section titled “Properties”cause?
Section titled “cause?”
optionalcause?:unknown
retryAfter?
Section titled “retryAfter?”
optionalretryAfter?: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.
Variables
Section titled “Variables”DEFAULT_RETRY_DELAY_MS
Section titled “DEFAULT_RETRY_DELAY_MS”
constDEFAULT_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.
References
Section titled “References”orFail
Section titled “orFail”Re-exports orFail