Skip to content

feat(core): exception transitions via on_error capture and wildcard routing (#30)#796

Open
andreahlert wants to merge 2 commits into
mainfrom
aex/exception-transitions
Open

feat(core): exception transitions via on_error capture and wildcard routing (#30)#796
andreahlert wants to merge 2 commits into
mainfrom
aex/exception-transitions

Conversation

@andreahlert
Copy link
Copy Markdown
Collaborator

Summary

Implements #30 "Add exception transitions". Currently an exception in an action breaks control flow and stops the program. This adds the ability to suppress an exception, capture it into state, and route to an error-handling action via a wildcard transition.

Scope chosen: capture + wildcard (combines ideas #3 and #4 from the issue thread).

from burr.core import action, capture_as, expr, ApplicationBuilder, State

@action(reads=[], writes=["output"], on_error=capture_as("error"))
def flaky(state: State) -> tuple[dict, State]:
    result = {"output": call_some_api(...)}
    return result, state.update(**result)

@action(reads=["error"], writes=[])
def handler(state: State) -> tuple[dict, State]:
    ...  # inspect / reset state["error"], then route onward

app = (
    ApplicationBuilder()
    .with_actions(flaky=flaky, handler=handler)
    .with_state(error=None)  # seed: expr() raises on a missing key
    .with_transitions(("*", "handler", expr("error is not None")))
    .with_entrypoint("flaky")
    .build()
)

What's included

  • capture_as(field, include_traceback=True) — an on_error handler that writes a JSON-serializable {type, message, traceback} record into a state field. Exported from burr.core.
  • @action(..., on_error=handler) — per-action handler, any callable (State, Exception) -> State.
  • ApplicationBuilder.with_error_handling(handler) — builder-level global handler for actions without their own on_error. Per-action takes precedence.
  • Wildcard ("*", target, condition) transitions — route from any action.

The captured field bypasses reducer write-validation (need not be in writes). A failing handler never masks the original exception (re-raised with the handler error as cause). Captured records are JSON-serializable, so persistence/tracking are unaffected.

Behavior changes to call out for review

These go beyond the headline feature, kept as separate commits:

  1. 4-tier transition resolution (fix(core) commit). The fix for the precedence bug that kept Add exception transitions #30 open: an action's own default transition shadowed a guarded wildcard route, so the route never fired. get_next_node now resolves: source non-default, wildcard non-default, source default, wildcard default. Semantic change: in default-FIRST graphs a previously-dead non-default transition becomes live. Default-LAST graphs (the common case) and graphs without wildcards are unchanged, covered by a regression test.
  2. _astep restructure. A SYNC action driven through the async path now reports the real result/state to the async post_run_step hook instead of stale values (it previously delegated to _step and discarded them). Locked by a regression test.

Out of scope (follow-up)

Streaming error handling (@streaming_action on_error during incremental stream_result/astream_result consumption) is intentionally not included, to avoid shipping a silently inert API. Tracked as a follow-up on #30.

Testing

  • pytest tests/core/ green (363 tests, +17 new).
  • Full non-integration suite green (416 passed, 1 skipped).
  • Verified wildcard graphs survive visualize() and ApplicationModel serialization (the tracking UI path).
  • flake8 clean, black/isort applied.

Process

The design never converged on the list (the issue thread has 4 competing shapes). This PR is a concrete proposal. I will start a [DISCUSS] thread on dev@burr.apache.org referencing this branch before merge, per podling process. Feedback on the precedence semantics in particular is welcome.

Closes #30.

Introduces wildcard transition sources so a graph can route from ANY action
to a handler, e.g. ("*", "handler", expr("error is not None")).

Resolves the transition-precedence bug that blocked issue #30: an action's
own default transition would shadow a guarded wildcard route, so the route
never fired. get_next_node now resolves in 4 tiers, first match wins:

  1. source-specific non-default transitions (insertion order)
  2. wildcard non-default transitions (insertion order)
  3. source-specific default transition
  4. wildcard default transition

This is a core-routing semantic change: in default-FIRST graphs a previously
dead non-default transition becomes live. For the common default-LAST graphs
(and graphs without wildcards) resolution is identical to before; covered by
a regression test.

The wildcard source is carried by a sentinel Result().with_name("*") used
only as a transition from_; it is never added to the action map, so
introspection (visualize, ApplicationModel serialization) is unaffected.

Part of #30.

Signed-off-by: André Ahlert <andre@aex.partners>
Adds the ability to suppress an action exception and route to an
error-handling action instead of breaking control flow.

  * capture_as(field, include_traceback=True): an on_error handler that
    writes a JSON-serializable record {type, message, traceback} into a
    state field. Exported from burr.core.
  * @action(..., on_error=handler): per-action error handler, any callable
    (State, Exception) -> State.
  * ApplicationBuilder.with_error_handling(handler): builder-level global
    handler applied to actions without their own on_error. Per-action wins.

When an action raises, the effective handler suppresses the exception,
writes captured state, and execution continues; a wildcard transition
("*", handler, expr("error is not None")) then routes to the handler.
The captured field bypasses reducer write-validation and need not be in
the action's declared writes. A failing handler never masks the original
exception (re-raised with the handler error as cause). The capture field
must be seeded (e.g. .with_state(error=None)) since expr() raises on a
missing key; documented in the capture_as example.

Also restructures _astep so a SYNC action driven through the async path
reports the real result/state to the async post_run_step hook instead of
stale values (previously delegated to _step and discarded). Behavior is
locked by a regression test.

Streaming error handling (@streaming_action on_error during incremental
consumption) is intentionally out of scope and left as follow-up.

Closes #30.

Signed-off-by: André Ahlert <andre@aex.partners>
@github-actions github-actions Bot added the area/core Application, State, Graph, Actions label Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core Application, State, Graph, Actions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add exception transitions

1 participant