Defects and Exceptions
FsFlow distinguishes between expected failures, administrative signals (interruption), and unexpected defects. This separation ensures that your domain logic remains clean while the runtime provides robust, leak-proof resource management.
Quick Start: Usage Patterns
Producing Failures
Choose the function that matches your intent:
| Intent | Function | Outcome |
|---|---|---|
| Domain Error (Expected) | Flow.fail "Not found" |
Cause.Fail "Not found" |
| Defect/Panic (Bug) | Flow.die (exn "Database down") |
Cause.Die exn |
| Interruption | Flow.interrupt or runtime cancellation |
Cause.Interrupt |
| Sequential Failures | Workflow fails, then cleanup fails | Cause.Then (workflowCause, cleanupCause) |
| Parallel Failures | Parallel branches both fail | Cause.Both (leftCause, rightCause) |
Bridging Exceptions
Use Flow.catch to convert specific exceptions into domain errors. Exceptions not caught by the handler will remain as Cause.Die.
let safeParse id =
flow {
let! json = Http.get id
return Json.parse json
}
|> Flow.catch (function
| :? JsonException as ex -> DomainError.InvalidFormat ex.Message
| ex -> reraise ex) // Bubbles up as Cause.Die
The “Why”: Architectural Rationale
While standard F# practice favors “just using exceptions” for defects, FsFlow treats them as first-class data in the Exit type for three critical reasons.
1. Structural Integrity (The “Closed” Algebra)
In complex orchestration like Flow.zipPar (running two flows concurrently), the engine must coordinate the lifecycle of multiple fibers.
- The Problem: If a defect is just a thrown exception, it escapes the return value of the function. The engine would have to handle two disjoint failure paths: returning a failure value OR catching a thrown exception. This forces every combinator to use defensive
try...finallyblocks just to coordinate basic signaling. - The Solution: By capturing defects into the
Exittype, every flow execution returns a value. This makes the algebra “closed.” If one branch dies, the engine receives it as data, immediately triggers cancellation for the other branches, and returns a single, structured outcome.
2. Lossless Concurrency Coordination
When a fiber fails, you often need to perform cleanup (e.g., ensuring or onExit).
By reifying defects into Cause.Die, FsFlow passes the exact cause, including the original exception and stack trace, to your finalizers as a value. This enables high-fidelity observability: you can log exactly why a background fiber died without crashing the host process, and without needing a try...with block inside every finalizer.
If cleanup itself fails after the workflow has already failed, FsFlow does not discard either side. It returns Cause.Then (workflowCause, cleanupCause) so observability and host boundaries can see the original failure and the cleanup defect in order.
3. Precision in Retries and Fallbacks
The distinction between Fail and Die allows for smarter defaults:
- Retries should usually target
Fail(e.g., a transient network error), but neverDie(e.g., aNullReferenceException). Retrying a bug is usually a waste of resources. - Fallbacks (
orElse) usually target domain failures. If a workflow has a defect, it usually indicates a corrupted state that fallback logic wasn’t designed to handle.