{"record":{"id":"28377d90b1449796","repo":"anomalyco/sst","slug":"failed-to-fail-workflow-callback","errorCode":null,"errorMessage":"Failed to fail workflow callback","messagePattern":"Failed to fail workflow callback","errorType":"error_code","errorClass":"FailError","httpStatus":null,"severity":"error","filePath":"sdk/js/src/aws/workflow.ts","lineNumber":465,"sourceCode":"    token: string,\n    input: FailInput,\n    options?: Options,\n  ): Promise<void> {\n    const response = await awsFetch(\n      \"lambda\",\n      `/2025-12-01/durable-execution-callbacks/${encodeURIComponent(\n        token,\n      )}/fail`,\n      {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify(normalizeError(input.error)),\n      },\n      options,\n    );\n    if (!response.ok) throw new FailError(response);\n  }\n\n  /**\n   * Send a heartbeat for a pending workflow callback.\n   *\n   * This is useful when the external system handling the callback is still doing\n   * work and needs to prevent the callback from timing out.\n   *\n   * This is the equivalent to calling\n   * [`SendDurableExecutionCallbackHeartbeat`](https://docs.aws.amazon.com/lambda/latest/api/API_SendDurableExecutionCallbackHeartbeat.html).\n   */\n  export async function heartbeat(\n    token: string,\n    options?: Options,\n  ): Promise<void> {\n    const response = await awsFetch(\n      \"lambda\",\n      `/2025-12-01/durable-execution-callbacks/${encodeURIComponent(","sourceCodeStart":447,"sourceCodeEnd":483,"githubUrl":"https://github.com/anomalyco/sst/blob/a0bd20f762883e72a35caccb4896c42ce5b3f707/sdk/js/src/aws/workflow.ts#L447-L483","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nawait fail(token, { error: plainJsError }); // throws if token expired/used\n// after\ntry {\n  await fail(token, { error: { errorType: \"JobFailed\", errorMessage: String(plainJsError) } });\n} catch (err) {\n  if (err instanceof FailError && err.response.status >= 400 && err.response.status < 500) {\n    console.warn(\"callback already resolved or expired\", token);\n    return;\n  }\n  throw err;\n}","handlingStrategy":"try-catch","validationCode":"// ensure error is serializable and token used once\nif (!token) throw new Error(\"missing callback token\");\nif (deliveredTokens.has(token)) return;\nconst error = { errorType: e.name ?? \"Error\", errorMessage: String(e.message ?? e) };","typeGuard":"function isFailError(err: unknown): err is FailError {\n  return err instanceof FailError && typeof err.response?.status === \"number\";\n}","tryCatchPattern":"try {\n  await fail(token, { error: { errorType: e.name, errorMessage: e.message } });\n  deliveredTokens.add(token);\n} catch (err) {\n  if (err instanceof FailError && err.response.status >= 400 && err.response.status < 500) {\n    console.warn(\"callback expired or already resolved:\", token, err.response.status);\n    return; // idempotent handling\n  }\n  throw err; // retry 429/5xx\n}","preventionTips":["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."],"tags":["aws","http","lambda","callback","durable-executions","token-expired"],"backgroundTag":"callback-token-invalid-or-expired","analyzedSha":"a0bd20f762883e72a35caccb4896c42ce5b3f707","analyzedAt":"2026-08-30T11:26:00.383Z","schemaVersion":2},"datasetVersion":"2026-08-30T13:17:10.514Z"}