Skip to content

Commit 3245b8b

Browse files
fahchenclaude
andcommitted
feat(runner): add error handling foundation
Introduce the foundation slice (P0-1, P0-2, P0-6) of the error handling overhaul described in error_handling_design.md. - Add `ColouredFlow.Runner.Errors` facade for centralised error classification: `tier/1`, `error_code/1`, `lifecycle?/1`, and `to_persisted_reason/1`. - Add a stable `error_code` field to existing public exceptions (`NonLiveWorkitem`, `InvalidWorkitemTransition`, `UnboundActionOutput`, `UnsufficientTokensToConsume`, `ColourSetMismatch`, `InvalidResult`). - Add four new public exceptions for the caller-facing error surface: `EnactmentNotRunning`, `EnactmentTimeout`, `EnactmentCallFailed`, `StoragePersistenceFailed`. - Add caller-safe wrapper `WorkitemTransition.call_enactment/3` that normalises the full `GenServer.call/3` `:exit` surface (`:noproc`, `:timeout`, `:shutdown`, `:normal`, `:nodedown`, `:killed`, `:calling_self`, catch-all) into typed exceptions returned via `{:error, exception}`. - Update `Enactment.Supervisor.terminate_enactment/2` to use the same wrapper; return type changes from `:ok` to `:ok | {:error, Exception.t()}`. - Add `Registry.whereis/1` helper for explicit pid lookup. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7719c7a commit 3245b8b

15 files changed

Lines changed: 1002 additions & 15 deletions

error_handling_design.md

Lines changed: 688 additions & 0 deletions
Large diffs are not rendered by default.

lib/coloured_flow/definition/colour_set/colour_set_mismatch.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ defmodule ColouredFlow.Definition.ColourSet.ColourSetMismatch do
88
typed_structor definer: :defexception, enforce: true do
99
field :colour_set, ColouredFlow.Definition.ColourSet.t()
1010
field :value, term()
11+
field :error_code, atom(), default: :colour_set_mismatch
1112
end
1213

1314
@impl Exception

lib/coloured_flow/expression/invalid_result.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule ColouredFlow.Expression.InvalidResult do
1212
field :expression, Expression.t()
1313

1414
field :message, String.t()
15+
field :error_code, atom(), default: :expression_invalid_result
1516
end
1617

1718
@impl Exception

lib/coloured_flow/runner/enactment/registry.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,16 @@ defmodule ColouredFlow.Runner.Enactment.Registry do
1414
def via_name({:enactment, id}) do
1515
{:via, Registry, {__MODULE__, {:enactment, id}}}
1616
end
17+
18+
@doc """
19+
Look up the pid registered under the given key, returning `{:ok, pid}` or
20+
`:error` if no process is registered.
21+
"""
22+
@spec whereis({:enactment, Storage.enactment_id()}) :: {:ok, pid()} | :error
23+
def whereis({:enactment, id}) do
24+
case Registry.lookup(__MODULE__, {:enactment, id}) do
25+
[{pid, _value}] -> {:ok, pid}
26+
[] -> :error
27+
end
28+
end
1729
end

lib/coloured_flow/runner/enactment/supervisor.ex

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ defmodule ColouredFlow.Runner.Enactment.Supervisor do
66
use DynamicSupervisor
77

88
alias ColouredFlow.Runner.Enactment
9-
alias ColouredFlow.Runner.Enactment.Registry
9+
alias ColouredFlow.Runner.Enactment.WorkitemTransition
1010
alias ColouredFlow.Runner.Storage
1111

1212
@typep enactment_id() :: Storage.enactment_id()
@@ -35,11 +35,16 @@ defmodule ColouredFlow.Runner.Enactment.Supervisor do
3535

3636
@doc """
3737
Terminate an enactment forcibly.
38+
39+
Returns `:ok` on success, or `{:error, exception}` when the target enactment is
40+
not running, the call times out, or the call exits abnormally.
3841
"""
39-
@spec terminate_enactment(enactment_id(), options :: [message: String.t()]) :: :ok
42+
@spec terminate_enactment(enactment_id(), options :: [message: String.t()]) ::
43+
:ok | {:error, Exception.t()}
4044
def terminate_enactment(enactment_id, options \\ []) do
41-
enactment = Registry.via_name({:enactment, enactment_id})
42-
43-
GenServer.call(enactment, {:terminate, options})
45+
case WorkitemTransition.call_enactment(enactment_id, {:terminate, options}) do
46+
:ok -> :ok
47+
{:error, _exception} = error -> error
48+
end
4449
end
4550
end

lib/coloured_flow/runner/enactment/workitem_transition.ex

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,39 @@ defmodule ColouredFlow.Runner.Enactment.WorkitemTransition do
22
@moduledoc """
33
Workitem transition functions, which are dispatched to the corresponding
44
enactment gen_server.
5+
6+
All public functions in this module funnel `GenServer.call/3` through a
7+
caller-safe wrapper that translates the full `:exit` surface (`:noproc`,
8+
`:timeout`, `:nodedown`, `:shutdown`, `:normal`, `:killed`, ...) into typed
9+
exceptions returned via `{:error, exception}`. Callers therefore never have to
10+
handle raw process-exit signals.
511
"""
612

713
alias ColouredFlow.Enactment.Occurrence
814

915
alias ColouredFlow.Runner.Enactment.Registry
1016
alias ColouredFlow.Runner.Enactment.Workitem
17+
alias ColouredFlow.Runner.Exceptions
1118
alias ColouredFlow.Runner.Storage
1219

1320
@typep enactment_id() :: Storage.enactment_id()
1421
@typep workitem_id() :: Workitem.id()
1522

23+
@call_timeout 5_000
24+
1625
@spec start_workitem(enactment_id(), workitem_id()) ::
1726
{:ok, Workitem.t(:started)} | {:error, Exception.t()}
1827
def start_workitem(enactment_id, workitem_id) do
1928
case start_workitems(enactment_id, [workitem_id]) do
2029
{:ok, [workitem]} -> {:ok, workitem}
21-
{:error, exception} -> {:error, exception}
30+
{:error, _exception} = error -> error
2231
end
2332
end
2433

2534
@spec start_workitems(enactment_id(), [workitem_id()]) ::
2635
{:ok, [Workitem.t(:started)]} | {:error, Exception.t()}
2736
def start_workitems(enactment_id, workitem_ids) when is_list(workitem_ids) do
28-
enactment = via_name(enactment_id)
29-
30-
GenServer.call(enactment, {:start_workitems, workitem_ids})
37+
call_enactment(enactment_id, {:start_workitems, workitem_ids})
3138
end
3239

3340
@spec complete_workitem(
@@ -37,7 +44,7 @@ defmodule ColouredFlow.Runner.Enactment.WorkitemTransition do
3744
def complete_workitem(enactment_id, {workitem_id, outputs}) do
3845
case complete_workitems(enactment_id, [{workitem_id, outputs}]) do
3946
{:ok, [workitem]} -> {:ok, workitem}
40-
{:error, exception} -> {:error, exception}
47+
{:error, _exception} = error -> error
4148
end
4249
end
4350

@@ -46,12 +53,73 @@ defmodule ColouredFlow.Runner.Enactment.WorkitemTransition do
4653
Enumerable.t({workitem_id(), Occurrence.free_binding()})
4754
) :: {:ok, [Workitem.t(:completed)]} | {:error, Exception.t()}
4855
def complete_workitems(enactment_id, workitem_id_and_outputs_list) do
49-
enactment = via_name(enactment_id)
50-
51-
GenServer.call(enactment, {:complete_workitems, workitem_id_and_outputs_list})
56+
call_enactment(enactment_id, {:complete_workitems, workitem_id_and_outputs_list})
5257
end
5358

54-
defp via_name(enactment_id) do
55-
Registry.via_name({:enactment, enactment_id})
59+
@doc """
60+
Caller-safe wrapper around `GenServer.call/3` against an enactment's via name.
61+
62+
Returns the GenServer reply unchanged on success (`{:ok, _}` or `{:error, _}`),
63+
or normalises any `:exit` from a missing/dying/timing-out process into a typed
64+
exception returned via `{:error, exception}`. The full exit surface is
65+
enumerated below; unknown exit reasons fall through to `EnactmentCallFailed`.
66+
"""
67+
# credo:disable-for-lines:2 JetCredo.Checks.ExplicitAnyType
68+
@spec call_enactment(enactment_id(), term(), timeout()) ::
69+
term() | {:error, Exception.t()}
70+
def call_enactment(enactment_id, message, timeout \\ @call_timeout) do
71+
case Registry.whereis({:enactment, enactment_id}) do
72+
:error ->
73+
{:error,
74+
Exceptions.EnactmentNotRunning.exception(
75+
enactment_id: enactment_id,
76+
reason: :not_started
77+
)}
78+
79+
{:ok, pid} ->
80+
try do
81+
GenServer.call(pid, message, timeout)
82+
catch
83+
:exit, {:noproc, _info} ->
84+
{:error,
85+
Exceptions.EnactmentNotRunning.exception(
86+
enactment_id: enactment_id,
87+
reason: :not_started
88+
)}
89+
90+
:exit, {:timeout, _info} ->
91+
{:error,
92+
Exceptions.EnactmentTimeout.exception(
93+
enactment_id: enactment_id,
94+
timeout: timeout
95+
)}
96+
97+
:exit, {:shutdown, _info} ->
98+
{:error,
99+
Exceptions.EnactmentNotRunning.exception(
100+
enactment_id: enactment_id,
101+
reason: :shutting_down
102+
)}
103+
104+
:exit, {:normal, _info} ->
105+
{:error,
106+
Exceptions.EnactmentNotRunning.exception(
107+
enactment_id: enactment_id,
108+
reason: :stopped_during_call
109+
)}
110+
111+
:exit, {:calling_self, _info} = reason ->
112+
# Programming error — surface it.
113+
exit(reason)
114+
115+
:exit, reason ->
116+
# Catch-all: :killed, {:nodedown, _}, or any other crash mid-call.
117+
{:error,
118+
Exceptions.EnactmentCallFailed.exception(
119+
enactment_id: enactment_id,
120+
reason: reason
121+
)}
122+
end
123+
end
56124
end
57125
end

lib/coloured_flow/runner/errors.ex

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
defmodule ColouredFlow.Runner.Errors do
2+
@moduledoc """
3+
Central error classification helpers for `ColouredFlow.Runner`.
4+
5+
Pattern-matches on known exception modules and returns their tier, stable error
6+
code, and persisted reason mapping. New exception modules in the runner must be
7+
registered here.
8+
9+
## Tiers
10+
11+
| Tier | Description |
12+
| ---- | ----------------------------------------------------------------------------------------------------- |
13+
| `1` | Caller-visible operational errors. Returned from public API; enactment process state unchanged. |
14+
| `2` | Enactment-fatal errors. Persisted to `enactments.state = :exception`; gracefully stops the GenServer. |
15+
| `3` | Transient infrastructure errors. Let it crash; supervisor restarts. |
16+
| `4` | Programmer errors. Should be caught by validators or compile-time checks. |
17+
18+
See `error_handling_design.md` at the repository root for the full
19+
specification.
20+
"""
21+
22+
alias ColouredFlow.Definition.ColourSet.ColourSetMismatch
23+
alias ColouredFlow.Expression.InvalidResult
24+
alias ColouredFlow.Runner.Exception, as: PersistedException
25+
alias ColouredFlow.Runner.Exceptions
26+
27+
@doc """
28+
Returns the tier (1–4) of the given exception when surfaced through the runner.
29+
30+
Tier classification is best-effort static — the same exception type may belong
31+
to different tiers depending on call site. Callers that need precise tiering
32+
should track it at the originating site rather than rely solely on this
33+
function.
34+
"""
35+
@spec tier(Exception.t()) :: 1 | 2 | 3 | 4
36+
def tier(%Exceptions.NonLiveWorkitem{}), do: 1
37+
def tier(%Exceptions.InvalidWorkitemTransition{}), do: 1
38+
def tier(%Exceptions.UnsufficientTokensToConsume{}), do: 1
39+
def tier(%Exceptions.UnboundActionOutput{}), do: 1
40+
def tier(%Exceptions.EnactmentNotRunning{}), do: 1
41+
def tier(%Exceptions.EnactmentTimeout{}), do: 1
42+
def tier(%Exceptions.EnactmentCallFailed{}), do: 1
43+
def tier(%Exceptions.StoragePersistenceFailed{}), do: 1
44+
def tier(%ColourSetMismatch{}), do: 1
45+
def tier(%InvalidResult{}), do: 1
46+
def tier(_other), do: 3
47+
48+
@doc """
49+
Returns the stable error code atom for the exception.
50+
51+
For exceptions defined in this codebase the code is stored on the struct as the
52+
`:error_code` field. For foreign exceptions (e.g. `ArithmeticError`,
53+
`RuntimeError`) the function returns `:unknown` — callers should treat foreign
54+
exceptions as Tier 3 transient or Tier 4 programmer errors.
55+
"""
56+
@spec error_code(Exception.t()) :: atom()
57+
def error_code(%{error_code: code}) when is_atom(code), do: code
58+
def error_code(_other), do: :unknown
59+
60+
@doc """
61+
Returns `true` if the exception, when raised inside an enactment, should trigger
62+
a lifecycle `:exception` event (i.e. a persisted state transition to
63+
`:exception`).
64+
65+
An exception is lifecycle-relevant when it has a corresponding persisted reason
66+
(see `to_persisted_reason/1`). Equivalent to `tier(exception) == 2` once Tier 2
67+
exception modules are registered, but the persisted-reason dispatch is the
68+
authoritative check.
69+
"""
70+
@spec lifecycle?(Exception.t()) :: boolean()
71+
def lifecycle?(exception), do: not is_nil(to_persisted_reason(exception))
72+
73+
@doc """
74+
Maps an exception to a persisted fatal reason atom, if one applies.
75+
76+
The persisted reason is the value stored in `enactment_logs.exception.reason`
77+
(`Ecto.Enum` constrained to `ColouredFlow.Runner.Exception.__reasons__/0`).
78+
Returns `nil` for exceptions that should not produce a persisted enactment
79+
exception state.
80+
"""
81+
@spec to_persisted_reason(Exception.t()) :: PersistedException.reason() | nil
82+
def to_persisted_reason(%InvalidResult{}), do: :termination_criteria_evaluation
83+
def to_persisted_reason(_other), do: nil
84+
end
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
defmodule ColouredFlow.Runner.Exceptions.EnactmentCallFailed do
2+
@moduledoc """
3+
Raised when a public Runner API call to an enactment fails with an exit reason
4+
that does not map to a more specific exception (`EnactmentNotRunning` or
5+
`EnactmentTimeout`). Carries the original exit reason for postmortem use.
6+
7+
Common cases include the called process being killed (`:killed`), the remote
8+
node going down (`{:nodedown, node}`), or any other unhandled `GenServer.call`
9+
exit pattern.
10+
"""
11+
12+
use TypedStructor
13+
14+
alias ColouredFlow.Runner.Storage
15+
16+
typed_structor definer: :defexception, enforce: true do
17+
field :enactment_id, Storage.enactment_id()
18+
field :reason, term()
19+
field :error_code, atom(), default: :enactment_call_failed
20+
end
21+
22+
# credo:disable-for-next-line JetCredo.Checks.ExplicitAnyType
23+
@impl Exception
24+
def exception(arguments) do
25+
struct!(__MODULE__, arguments)
26+
end
27+
28+
@impl Exception
29+
def message(%__MODULE__{} = exception) do
30+
"""
31+
The call to enactment #{exception.enactment_id} failed with reason: #{inspect(exception.reason)}.
32+
"""
33+
end
34+
end
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
defmodule ColouredFlow.Runner.Exceptions.EnactmentNotRunning do
2+
@moduledoc """
3+
Raised when a public Runner API call targets an enactment that is not currently
4+
running. Possible causes include the enactment never having been started, the
5+
enactment having terminated, having transitioned to `:exception`, or being in
6+
the middle of a graceful shutdown when the call arrived.
7+
"""
8+
9+
use TypedStructor
10+
11+
alias ColouredFlow.Runner.Storage
12+
13+
@type reason() :: :not_started | :shutting_down | :stopped_during_call | :unknown
14+
15+
typed_structor definer: :defexception, enforce: true do
16+
field :enactment_id, Storage.enactment_id()
17+
field :reason, reason(), default: :unknown
18+
field :error_code, atom(), default: :enactment_not_running
19+
end
20+
21+
@impl Exception
22+
def exception(arguments) do
23+
struct!(__MODULE__, arguments)
24+
end
25+
26+
@impl Exception
27+
def message(%__MODULE__{} = exception) do
28+
"""
29+
The enactment with ID #{exception.enactment_id} is not running (#{exception.reason}).
30+
"""
31+
end
32+
end
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
defmodule ColouredFlow.Runner.Exceptions.EnactmentTimeout do
2+
@moduledoc """
3+
Raised when a public Runner API call to an enactment exceeds the configured call
4+
timeout. The enactment process may still be alive and may eventually complete
5+
the requested operation; the caller has merely stopped waiting.
6+
"""
7+
8+
use TypedStructor
9+
10+
alias ColouredFlow.Runner.Storage
11+
12+
typed_structor definer: :defexception, enforce: true do
13+
field :enactment_id, Storage.enactment_id()
14+
field :timeout, non_neg_integer() | :infinity
15+
field :error_code, atom(), default: :enactment_timeout
16+
end
17+
18+
@impl Exception
19+
def exception(arguments) do
20+
struct!(__MODULE__, arguments)
21+
end
22+
23+
@impl Exception
24+
def message(%__MODULE__{} = exception) do
25+
"""
26+
The call to enactment #{exception.enactment_id} timed out after #{exception.timeout}ms.
27+
"""
28+
end
29+
end

0 commit comments

Comments
 (0)