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.

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.

Channel

ChannelMessage

Promise<string>

A FatalError or RetryableError — see toStepError.

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(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. responseErrorMessage prefers a JSON error field when the far side sent one and falls back to the status with a bounded preview. Hand-written versions throw away the body — so a 400 that said exactly what was wrong with the request arrives as the number 400, and whoever reads the run has to reproduce the call to find out.
  • The verdict stays with toStepError. Transient by isTransientStatus, waiting out a Retry-After the server named rather than the default one-second delay. That distinction is the reason a step should never throw a bare Error on 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.

string

StepFetchInit

Promise<Response>

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();
}

a FatalError or RetryableError — see toStepError.


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.

S extends StandardSchemaV1<unknown, unknown>

string

StepGenerateJsonOptions<S>

Promise<InferSchemaOutput<S>>

A FatalError or RetryableError — see toStepError.


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, …)).

string

StepGenerateOptions

Promise<string>

A FatalError or RetryableError — see toStepError.

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(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.

string

TranscribeRequestOptions

Promise<TranscribeProgress>

A FatalError or RetryableError — see toStepError.


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.

string

TranscribeSubmitOptions

Promise<{ id: string; }>

A FatalError or RetryableError — see toStepError.


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.

Uint8Array<ArrayBufferLike> | readonly Uint8Array<ArrayBufferLike>[]

TranscribeSyncOptions

Promise<{ text: string; }>

A FatalError or RetryableError — see toStepError.


stepTranscribeUploadOrFail(uploadId, options?): Promise<{ audioUrl: string; }>

stepTranscribeUpload, with its failure classified — see stepTranscribeSyncOrFail for what a transcription verdict carries.

string

TranscribeRequestOptions

Promise<{ audioUrl: string; }>

A FatalError or RetryableError — see toStepError.


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.

RetryableErrorOptions = object

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.