Skip to content

feat(execution): classify reference executor errors as permanent or transient - #130

Open
dawidaksamski wants to merge 12 commits into
feat/WB-596-connection-configfrom
feat/WB-525-classify-executor-errors
Open

feat(execution): classify reference executor errors as permanent or transient#130
dawidaksamski wants to merge 12 commits into
feat/WB-596-connection-configfrom
feat/WB-525-classify-executor-errors

Conversation

@dawidaksamski

Copy link
Copy Markdown
Contributor

What

Executors threw plain errors, so Temporal retried everything, including calls that fail identically every time: a rejected API key, a 400, a template typo, a decision with no matching branch. This is the reviewed classification pass over the reference executors. Every judgment is declared at the throw site; the runner and the adapter still never infer a class from a status code.

Failure Class Code
AI Agent: provider answered 401 or 403 permanent provider_auth_rejected
AI Agent: provider answered any other 4xx except 408 and 429 permanent provider_rejected_request
AI Agent: provider answered 429 transient provider_rate_limited
AI Agent: provider answered 5xx transient provider_unavailable
AI Agent: provider answered 408, or the connection failed transient provider_unreachable
AI Agent, Decision: template reference malformed or unresolved permanent template_malformed, template_unresolved
Decision: no branch matched permanent no_branch_matched

Deliberate choices, both documented in the worker README:

  • The classifier keys on the SDK's APICallError.statusCode with its own table and never reads isRetryable, so an SDK upgrade cannot reclassify anything. 409 is permanent on purpose, unlike the SDK default.
  • Anything that is not a provider response stays unclassified: model-behaviour errors such as a malformed tool call, and a 2xx whose body the SDK could not parse. Unclassified keeps exactly the old uniform retry.

Changes by area

execution-core

  • resolveTemplate throws PermanentNodeExecutionError at its three throw sites. Messages are unchanged. A retried node receives the same context, so a bad reference can never resolve on attempt two.
  • extractDeepestError keeps the deepest non-empty message. Node's failed fetch ends in an AggregateError with an empty message, which otherwise reached node_failed and the AI Studio log panel as a blank line.

execution-worker

  • New classifyProviderError next to the AI agent activity, called from its catch block, with the table above. The provider's own error stays attached as cause, so node_failed keeps showing the provider's text; the classifier's message (which names the HTTP status) is visible in Temporal's failure record. Handles a provider error wrapped in the SDK's RetryError in case client retries are ever re-enabled.
  • no_branch_matched becomes permanent.
  • The no_branch_matched message no longer recommends "a catch-all branch with no conditions", which the executor never matches. It now asks for an always-true condition. The proper default-branch mechanism is WB-619 (follow-up: decision-default-branch).
  • README gains a "Failure classification" section.

ai-studio

  • The Support Triage template's "How-to / Other" branch had no conditions and therefore never matched; any ticket that was neither billing nor bug failed the run. It now carries an always-true condition.

temporal

  • Error-boundary test gains a transient case against a real local Temporal server: retries to the profile limit, node_failed carries the code and attempt, and the deepest cause's text is what reaches it.

Visible behaviour changes worth knowing

  • Permanent failures stop after one attempt instead of two.
  • no_branch_matched and the template codes reach node_failed for the first time. Unclassified codes were dropped at the Temporal boundary, so a consumer test asserting the old payload shape would break.
  • The runner, the activity adapter and the retry profiles are untouched.

Verification

  • pnpm test passes in every workspace. execution-core, execution-worker and temporal are the ones touched.
  • pnpm lint clean. pnpm typecheck fails only in apps/docs, which is the pre-existing failure on main.
  • Each commit builds and passes tests on its own.

No changeset: the worker is private and @workflowbuilder/temporal is unpublished; its queued first-release changeset covers the execution-core changes.

resolveTemplate threw plain errors, so a malformed or unresolved
reference was retried under the node profile even though a retried
node receives the same context and fails identically. The three throw
sites now raise PermanentNodeExecutionError with the codes
template_malformed and template_unresolved, so the engine stops on the
first attempt and the code reaches node_failed.
A decision node with no matching branch was retried under the node
profile, although the same inputs yield the same non-match on every
attempt. The throw site now raises PermanentNodeExecutionError, so the
node stops on its first attempt and the no_branch_matched code reaches
node_failed for the first time: unclassified codes are dropped at the
activity boundary.
The AI agent rethrew whatever the AI SDK threw, so a rejected API key
or a 400 burned every attempt the node profile allows. The catch block
now maps a provider response by status, at the throw site and nowhere
central: 401/403 and every other 4xx are permanent, 429, 5xx, 408 and
a request that never got an answer are transient. Anything that is not
a provider response passes through unclassified.

The provider's own error stays attached as the cause, so node_failed
keeps showing the provider's text; the HTTP status is in the message.
The log line gains the code. The worker README documents the table.
The boundary test proved the permanent and unclassified paths but not
the transient one. The new case runs a wrapped transient failure
through a real Temporal dev server and pins that the node retries to
the profile's limit, that node_failed carries the code and the attempt
it died on, and that the deepest cause's message is what reaches it.
…wrapper

classifyProviderError only matched a bare APICallError, so with SDK retries
enabled every provider failure would arrive as a RetryError and fall back
to the profile's uniform retry. The classifier now unwraps lastError first.

Also: the status-less branch is reached for connection failures, not for a
request that timed out, so the message and README row say so; the README no
longer claims every failure is classified; the activity log line uses
instanceof instead of a cast; the decision comment fits the three-line
ceiling; the unclassified boundary case no longer borrows the
no_branch_matched code this branch made permanent.
extractDeepestError returned the deepest cause's message verbatim. A
failed fetch in Node ends in an AggregateError with an empty message,
so a provider that refused the connection reached node_failed, and the
AI Studio log panel, as a blank line. The walk now keeps the deepest
non-empty message; everything else about the chain is unchanged.
The template tests grew a bespoke catch-and-return helper next to
twenty assertions written with toThrow; they now use toThrow with an
asymmetric matcher like the rest of the file. The graph-runner test
that models a template failure crossing an adapter builds the real
PermanentNodeExecutionError and asserts the code it now carries. The
comment above resolveTemplate restated the README and is gone.
…iberate choices

The README claimed the HTTP status reaches node_failed. It does not:
the event reports the deepest cause, so the provider's text is what
the UI shows and the status lives only in Temporal's failure record.
The paragraph now says so, states that 409 is permanent on purpose,
and names the unparsable-2xx case as intentionally unclassified. Two
comments that restated the READMEs or overstated the log line are
trimmed to what the code cannot show.
…2xx fall-through

The APICallError fixture was built in two test files; it now lives in
one. The classifier table is a single list keyed by class, with the
redundant classification literals dropped, and gains 409 plus the
unparsable-2xx case that passes through unclassified. The comment
about statusCode 500 moves back to the single-call test it describes,
and the decision test asserts with toThrow matchers.
…catch-all

The no_branch_matched message advised adding a catch-all branch with
no conditions, but a branch with no conditions never matches, so the
advice reproduced the failure it explained. The message now asks for
an always-true condition, the executor comment says why, and the test
pins the wording. Making an empty branch the catch-all is a separate
backlog item, marked with the follow-up slug in the comment.
…branch

The "How-to / Other" branch had no conditions, which the decision
executor never matches, so any ticket classified as neither billing
nor bug failed the run with no_branch_matched. The branch now carries
an always-true condition, the only form the executor treats as a
catch-all today.
…anch follow-up

The slug presumed that an empty branch will become the catch-all. The
backlog task proposes an explicit default flag instead, so the marker
now names the outcome neutrally.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants