anomalyco/sst · error · FailError
Failed to fail workflow callback
Error message
Failed to fail workflow callback
What it means
Thrown by the public `fail` callback function when the POST to `/2025-12-01/durable-execution-callbacks/{token}/fail` returns a non-OK HTTP status. It wraps the raw Response. The failure result was not delivered, so the workflow keeps waiting on the callback instead of resuming with the error.
Solutions
- Check the wrapped response status — 4xx usually means the token already resolved or expired; treat as idempotent if a terminal result was already delivered.
- Send periodic `heartbeat(token)` calls while the external work runs to prevent timeout before reporting failure.
- Ensure only one terminal call (succeed or fail) is made per token — coordinate in the worker with a state flag.
- Verify `input.error` serializes via normalizeError (pass { errorType, errorMessage }-shaped data) and check IAM permissions.
- Retry 429/5xx with backoff.
Example fix
// before
await fail(token, { error: plainJsError }); // throws if token expired/used
// after
try {
await fail(token, { error: { errorType: "JobFailed", errorMessage: String(plainJsError) } });
} catch (err) {
if (err instanceof FailError && err.response.status >= 400 && err.response.status < 500) {
console.warn("callback already resolved or expired", token);
return;
}
throw err;
} Defensive patterns
Strategy: try-catch
Validate before calling
// ensure error is serializable and token used once
if (!token) throw new Error("missing callback token");
if (deliveredTokens.has(token)) return;
const error = { errorType: e.name ?? "Error", errorMessage: String(e.message ?? e) }; Type guard
function isFailError(err: unknown): err is FailError {
return err instanceof FailError && typeof err.response?.status === "number";
} Try / catch
try {
await fail(token, { error: { errorType: e.name, errorMessage: e.message } });
deliveredTokens.add(token);
} catch (err) {
if (err instanceof FailError && err.response.status >= 400 && err.response.status < 500) {
console.warn("callback expired or already resolved:", token, err.response.status);
return; // idempotent handling
}
throw err; // retry 429/5xx
} Prevention
- Decide once per token whether to succeed or fail — use a flag/state store so both paths can't fire.
- Send heartbeats while external work runs so the token doesn't time out before you report the failure.
- Pass errors as { errorType, errorMessage } so normalizeError serializes them reliably.
- Grant lambda:SendDurableExecutionCallbackFailure to the calling identity.
When it happens
Trigger: Calling `Workflow.fail(token, input)` where: (1) the token is expired, timed out, or already resolved (e.g. a success was already sent, or fail called twice); (2) the parent execution was stopped or deleted; (3) `input.error` cannot be normalized/serialized or the body is rejected; (4) credentials lack `lambda:SendDurableExecutionCallbackFailure`; (5) throttling/transient 5xx.
Common situations: External workers reporting failure after the callback window lapsed (no heartbeats sent); race between success and fail paths in worker code; requeue/redelivery causing a second fail for the same token; IAM policies missing the callback-failure action.
Related errors
- Failed to succeed workflow callback
- Failed to describe workflow
- Failed to stop workflow
- Cannot set both "logging.retention" and "logging.logGroup"
- Copy destination path
AI-assisted analysis of anomalyco/sst@a0bd20f762 (2026-08-30).
Data as JSON: /api/errors/28377d90b1449796.
Report an issue: GitHub.
Appendix: source
Thrown at sdk/js/src/aws/workflow.ts:465
token: string,
input: FailInput,
options?: Options,
): Promise<void> {
const response = await awsFetch(
"lambda",
`/2025-12-01/durable-execution-callbacks/${encodeURIComponent(
token,
)}/fail`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(normalizeError(input.error)),
},
options,
);
if (!response.ok) throw new FailError(response);
}
/**
* Send a heartbeat for a pending workflow callback.
*
* This is useful when the external system handling the callback is still doing
* work and needs to prevent the callback from timing out.
*
* This is the equivalent to calling
* [`SendDurableExecutionCallbackHeartbeat`](https://docs.aws.amazon.com/lambda/latest/api/API_SendDurableExecutionCallbackHeartbeat.html).
*/
export async function heartbeat(
token: string,
options?: Options,
): Promise<void> {
const response = await awsFetch(
"lambda",
`/2025-12-01/durable-execution-callbacks/${encodeURIComponent(View on GitHub (pinned to a0bd20f762)