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”sendToChannelOrFail()
Section titled “sendToChannelOrFail()”sendToChannelOrFail(
channel,message):Promise<string>
sendToChannel (@alexkroman1/aai/channels), with its failure classified —
see stepGenerateOrFail for the family, and this module’s doc for
why the wrapper lives here rather than beside the call it wraps.
ChannelDeliveryError carries the platform’s verdict AND its Retry-After,
so a rate-limited post waits the delay the platform named rather than the
default one-second delay, and a 4xx — a revoked webhook, an unpublished
Slack workflow, a variable name that matches nothing — stops immediately
with the sentence a person can act on instead of burning three more attempts
on an answer that will not change.
Reach for sendToChannel directly where the refusal is not simply a
failure: a body deciding to fall back to a second destination, or a run that
treats an unreachable channel as a warning rather than an outcome.
Parameters
Section titled “Parameters”channel
Section titled “channel”message
Section titled “message”Returns
Section titled “Returns”Promise<string>
Throws
Section titled “Throws”A FatalError or RetryableError — see toStepError.
Example
Section titled “Example”import { slackChannel } from "@alexkroman1/aai/channels";import { sendToChannelOrFail } from "@alexkroman1/aai/step-errors";
export async function announce(webhookUrl: string, headline: string): Promise<string> { return await sendToChannelOrFail(slackChannel({ webhookUrl }), { text: headline });}stepFetchOrFail()
Section titled “stepFetchOrFail()”stepFetchOrFail(
url,init?):Promise<Response>
stepFetch, with the non-2xx branch every caller was writing by hand.
A step whose job is one HTTP call ends up writing the same three lines —
make the request, check ok, hand the Response to toStepError —
and three templates had each arrived at their own copy of it: meeting-recap-agent
wrapped it in a local request(), link-digest-workflow inlined it, and
podcast-digest-workflow wrote a fetchText around it. This is that line, and the
argument for hoisting it is the one in this module’s own doc: a snippet
copied verbatim into three places is a function that has not been written
yet.
It answers a Response on 2xx, so nothing about the success path changes —
the caller still chooses .text(), .json() or the stream. It is only the
failure path that is taken over, and the takeover is worth having for two
reasons beyond the line count:
- The body reaches the error.
responseErrorMessageprefers a JSONerrorfield when the far side sent one and falls back to the status with a bounded preview. Hand-written versions throw away the body — so a400that said exactly what was wrong with the request arrives as the number400, and whoever reads the run has to reproduce the call to find out. - The verdict stays with
toStepError. Transient byisTransientStatus, waiting out aRetry-Afterthe server named rather than the default one-second delay. That distinction is the reason a step should never throw a bareErroron a bad response, and it is easy to forget in the fourth call site of a file.
Reach for stepFetch directly where the failure is not simply a
failure: a 404 that means “already deleted”, or a 4xx whose body decides
which advice to print. podcast-digest-workflow’s Slack step is the worked example of
that second case.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”Promise<Response>
Example
Section titled “Example”import { stepFetchOrFail } from "@alexkroman1/aai/step-errors";
export async function readFeed(url: string): Promise<string> { return await (await stepFetchOrFail(url, { signal: AbortSignal.timeout(30_000) })).text();}Throws
Section titled “Throws”a FatalError or RetryableError — see toStepError.
stepGenerateJsonOrFail()
Section titled “stepGenerateJsonOrFail()”stepGenerateJsonOrFail<
S>(prompt,options):Promise<InferSchemaOutput<S>>
stepGenerateJson, with its failure classified — see
stepGenerateOrFail. The most-copied member of the family (7 of the
17 sites): a workflow that asks a model for a SHAPE is the usual shape.
Worth knowing what it does NOT flatten: a gateway refusal arrives as a
StepGenerateError carrying its own verdict, while a reply that was not JSON
or missed the schema throws a plain Error, which toStepError passes
through retryable — correctly, since a model that answered with prose may obey
next attempt.
Type Parameters
Section titled “Type Parameters”S extends StandardSchemaV1<unknown, unknown>
Parameters
Section titled “Parameters”prompt
Section titled “prompt”string
options
Section titled “options”Returns
Section titled “Returns”Promise<InferSchemaOutput<S>>
Throws
Section titled “Throws”A FatalError or RetryableError — see toStepError.
stepGenerateOrFail()
Section titled “stepGenerateOrFail()”stepGenerateOrFail(
prompt,options?):Promise<string>
stepGenerate, with its failure classified — the whole of what the wrapper
adds is throwStepError, and see this module’s doc for why that is
worth an export rather than a line at each of the eight templates that wrote
it. StepGenerateError carries the gateway’s own verdict AND its
Retry-After, so a rate-limited call waits the delay the gateway named
instead of the default one-second delay.
None of them takes a message: a caller with a label worth attaching wants
the explicit .catch((err) => throwStepError(err, …)).
Parameters
Section titled “Parameters”prompt
Section titled “prompt”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<string>
Throws
Section titled “Throws”A FatalError or RetryableError — see toStepError.
Example
Section titled “Example”import { stepGenerateOrFail } from "@alexkroman1/aai/step-errors";
export async function summarize(text: string): Promise<string> { return await stepGenerateOrFail(text, { system: "Summarize in two sentences." });}stepTranscribePollOrFail()
Section titled “stepTranscribePollOrFail()”stepTranscribePollOrFail(
transcriptId,options?):Promise<TranscribeProgress>
stepTranscribePoll, with its failure classified — see
stepTranscribeSubmitOrFail. A poll that answers is not a poll that
SUCCEEDED: an unfinished job comes back as a TranscribeProgress and only a
transport or API failure rejects, so this classifies the rejection and says
nothing about the job’s own status.
Parameters
Section titled “Parameters”transcriptId
Section titled “transcriptId”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<TranscribeProgress>
Throws
Section titled “Throws”A FatalError or RetryableError — see toStepError.
stepTranscribeSubmitOrFail()
Section titled “stepTranscribeSubmitOrFail()”stepTranscribeSubmitOrFail(
audioUrl,options?):Promise<{id:string; }>
stepTranscribeSubmit, with its failure classified — see
stepTranscribeSyncOrFail. Half of the async job API, whose other
half is stepTranscribePollOrFail; both are wrapped because a submit
and its poll are separate steps with separate attempt budgets — classify one
and not the other and the run gives up in one place and never in the other.
Parameters
Section titled “Parameters”audioUrl
Section titled “audioUrl”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<{ id: string; }>
Throws
Section titled “Throws”A FatalError or RetryableError — see toStepError.
stepTranscribeSyncOrFail()
Section titled “stepTranscribeSyncOrFail()”stepTranscribeSyncOrFail(
bytes,options?):Promise<{text:string; }>
stepTranscribeSync, with its failure classified — see
stepGenerateOrFail.
This is the arm where classifying earns the most. TranscribeError carries
retryable, and a refusal the PROVIDER decided — a recording with no speech in
it, a container it will not read — arrives with retryable: false. Unclassified,
a step re-uploads the same bytes until its attempts run out on a file that was
never going to transcribe.
Parameters
Section titled “Parameters”Uint8Array<ArrayBufferLike> | readonly Uint8Array<ArrayBufferLike>[]
options?
Section titled “options?”Returns
Section titled “Returns”Promise<{ text: string; }>
Throws
Section titled “Throws”A FatalError or RetryableError — see toStepError.
stepTranscribeUploadOrFail()
Section titled “stepTranscribeUploadOrFail()”stepTranscribeUploadOrFail(
uploadId,options?):Promise<{audioUrl:string; }>
stepTranscribeUpload, with its failure classified — see
stepTranscribeSyncOrFail for what a transcription verdict carries.
Parameters
Section titled “Parameters”uploadId
Section titled “uploadId”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<{ audioUrl: string; }>
Throws
Section titled “Throws”A FatalError or RetryableError — see toStepError.
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
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.
Type Aliases
Section titled “Type Aliases”RetryableErrorOptions
Section titled “RetryableErrorOptions”RetryableErrorOptions =
object
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.