--- status: draft last_updated: 2026-05-20 --- # Error Handling FlowgraphError hierarchy, validation error collection, and error boundaries. ## Design Principle Flowgraph follows taskgraph's error handling pattern: 1. **Programmer errors throw** — invalid arguments, duplicate node IDs, cycles where acyclicity is enforced 2. **Operational conditions return structured results** — validation errors, type mismatches, unreachable nodes 3. **Graph mutations throw on constraint violations** — adding a duplicate node throws `DuplicateNodeError`, adding a cycle-creating edge throws `CycleError` This means validation functions (`validateGraph()`, `validateSchema()`, `validateTemplate()`) *never throw* — they collect issues and return them. But construction functions (`fromSpecs()`, `addNode()`, `addEdge()`) *do throw* on constraint violations. ## Error Hierarchy ``` FlowgraphError # Base class for all flowgraph errors ├── ConstructionError # Errors during graph construction │ ├── DuplicateNodeError # Duplicate node key │ ├── DuplicateEdgeError # Duplicate edge key │ ├── NodeNotFoundError # Referenced node doesn't exist │ └── CycleError # Adding an edge would create a cycle ├── ValidationError # Schema validation failed (single field) ├── GraphValidationError # Graph-level validation issue │ ├── CycleValidationError # Cycle detected in the graph │ └── DanglingReferenceError # Edge references non-existent node ├── TypeIncompatError # Type compatibility check failed └── InvalidTransitionError # Invalid call status transition ``` ### FlowgraphError ```typescript class FlowgraphError extends Error { constructor(message: string) { super(message); this.name = "FlowgraphError"; } } ``` Base class. All flowgraph errors inherit from this. ### ConstructionError ```typescript class ConstructionError extends FlowgraphError { constructor(message: string) { super(message); this.name = "ConstructionError"; } } ``` Base class for errors that occur during graph construction (`fromSpecs()`, `addNode()`, `addEdge()`, etc.). ### DuplicateNodeError ```typescript class DuplicateNodeError extends ConstructionError { constructor(public readonly key: string) { super(`Node with key "${key}" already exists`); this.name = "DuplicateNodeError"; } } ``` Thrown when adding a node with a key that already exists in the graph. ### DuplicateEdgeError ```typescript class DuplicateEdgeError extends ConstructionError { constructor( public readonly source: string, public readonly target: string, ) { super(`Edge "${source} -> ${target}" already exists`); this.name = "DuplicateEdgeError"; } } ``` Thrown when adding an edge between two nodes that already have an edge between them. ### NodeNotFoundError ```typescript class NodeNotFoundError extends ConstructionError { constructor(public readonly key: string) { super(`Node "${key}" not found in graph`); this.name = "NodeNotFoundError"; } } ``` Thrown when referencing a node that doesn't exist (e.g., adding an edge with a non-existent endpoint). ### CycleError ```typescript class CycleError extends ConstructionError { constructor(public readonly cycles: string[][]) { super(`Adding this edge would create a cycle: ${JSON.stringify(cycles)}`); this.name = "CycleError"; } } ``` Thrown when adding an edge would create a cycle. The `cycles` field contains the cycle paths that would be created. Note: `CycleError` is flowgraph's cycle error, thrown by `addEdge()` during construction. Taskgraph uses a different error name (`CircularDependencyError`, thrown by `topologicalOrder()`). The two are distinct errors for distinct contexts — flowgraph prevents cycles at construction time, taskgraph allows cycles and detects them later. ### ValidationError ```typescript interface ValidationError { type: "schema"; nodeKey: string; field: string; message: string; value?: unknown; } ``` Returned by `validateSchema()` when a node's attributes don't match the TypeBox schema. This is a structured result, not a thrown error. ### GraphValidationError ```typescript interface GraphValidationError { type: "graph"; category: "cycle" | "dangling-reference" | "orphan-node" | "status-inconsistency"; details: unknown; } ``` Returned by `validateGraph()` for graph-level issues: | Category | Meaning | Details | |----------|---------|---------| | `cycle` | The graph contains cycles | `{ cycles: string[][] }` | | `dangling-reference` | An edge references a non-existent node | `{ source: string, target: string }` | | `orphan-node` | A node has no incoming or outgoing edges | `{ nodeKey: string }` | | `status-inconsistency` | A call node has incompatible status with its parent (e.g., parent completed but child still running) | `{ nodeKey: string, parentKey: string, nodeStatus: string, parentStatus: string }` | ### InvalidTransitionError ```typescript class InvalidTransitionError extends FlowgraphError { constructor( public readonly requestId: string, public readonly from: CallStatus, public readonly to: CallStatus, ) { super(`Invalid status transition for call ${requestId}: ${from} → ${to}`); this.name = "InvalidTransitionError"; } } ``` Thrown when `updateNodeStatus()` is called with an invalid transition (e.g., `completed → running`). ### TypeIncompatError ```typescript interface TypeIncompatError { type: "type-compat"; sourceKey: string; targetKey: string; compatible: false; mismatches: TypeMismatch[]; } interface TypeMismatch { path: string; expected: string; actual: string; } ``` Returned by `validateTemplate()` and `analyzeTypeCompat()` when an edge between operations has incompatible type schemas. This is a structured result, not a thrown error. ## Error Collection Validation functions collect all issues into an array and return them. They do not throw on the first error: ```typescript const errors = graph.validate(); // errors is AnyValidationError[], which may be empty for (const error of errors) { if (error.type === "schema") { console.log(`Node ${error.nodeKey} has invalid field ${error.field}: ${error.message}`); } else if (error.type === "graph" && error.category === "cycle") { console.log(`Graph has cycles: ${error.details.cycles}`); } } ``` This "collect all errors" pattern allows consumers to see all issues at once, rather than fixing them one at a time. ### AnyValidationError ```typescript type AnyValidationError = ValidationError | GraphValidationError | TypeIncompatError; ``` Union type for all validation errors. Consumers use the `type` discriminator to handle each category: ```typescript switch (error.type) { case "schema": // ValidationError case "graph": // GraphValidationError case "type-compat": // TypeIncompatError } ``` ## Throwing vs. Returning The distinction between thrown errors and returned errors: | Function | Behavior | Rationale | |----------|----------|-----------| | `addNode(key, attrs)` | Throws `DuplicateNodeError` on duplicate key | Adding a duplicate is a programmer error | | `addEdge(source, target)` | Throws `NodeNotFoundError` on missing endpoint | Edge without endpoints is invalid | | `addEdge(source, target)` | Throws `CycleError` if edge creates cycle | DAG invariant must be maintained | | `updateNodeStatus(id, status)` | Throws `InvalidTransitionError` on invalid transition | State machine must be enforced | | `validateSchema()` | Returns `ValidationError[]` | Schema issues are validations, not crashes | | `validateGraph()` | Returns `GraphValidationError[]` | Graph issues are validations, not crashes | | `validateTemplate()` | Returns `AnyValidationError[]` | Template issues are validations, not crashes | | `analyzeTypeCompat()` | Returns `TypeCompatResult` (includes mismatches) | Type incompatibility is advisory, not blocking | | `topologicalOrder()` | Throws `CycleError` on cycles | No valid ordering exists from a cyclic graph | This matches taskgraph's pattern: construction enforces invariants (throwing on violations), validation reports issues (returning error arrays). ## Error Boundaries in the Call Graph The call graph has an additional error boundary: `updateFromEvent()`. Call events arrive from the pub/sub layer and may reference unknown operations or have invalid transitions. The error boundary handles these gracefully: - **Unknown `requestId`** in a `call.responded` event → log warning, ignore the event (the call may have been created by a different process) - **Invalid status transition** → log warning, ignore the event (the call may have transitioned in a different order) - **Unknown `operationId`** → create the node anyway with `status: "pending"` (the operation may be registered later) This makes `updateFromEvent()` resilient to out-of-order, duplicate, and partial events. Errors are logged but don't crash the process. ## Constraints - **Validation functions never throw** — they collect errors and return them. This is a contract. - **Construction functions throw on invariant violations** — adding a cycle-creating edge is a programming error, not a validation finding. - **All errors have structured data** — `CycleError` includes cycle paths, `InvalidTransitionError` includes from/to status, `TypeIncompatError` includes mismatch details. - **Error messages are descriptive** — errors include enough context to diagnose the problem without additional lookups. - **Error classes follow the taskgraph pattern** — naming, structure, and behavior match `@alkdev/taskgraph_ts/src/error/`. ## References - Taskgraph errors: `@alkdev/taskgraph_ts/src/error/` - Call protocol events: `@alkdev/operations/src/call.ts` - Schema: [schema.md](schema.md)