From eb4cdd1db4b4cbf8ff11951b40e6488bdac096d9 Mon Sep 17 00:00:00 2001 From: sproott Date: Tue, 6 Oct 2026 13:19:27 +0200 Subject: [PATCH] Add skills for AI agents --- .../skills/feather-error-handling/SKILL.md | 53 +++ .../references/anti-patterns.md | 53 +++ .../references/examples.md | 357 ++++++++++++++++++ .../references/preferred-patterns.md | 57 +++ .apm/skills/feather-error-handling/SKILL.md | 53 +++ .../references/anti-patterns.md | 53 +++ .../references/examples.md | 357 ++++++++++++++++++ .../references/preferred-patterns.md | 57 +++ .../skills/feather-error-handling/SKILL.md | 53 +++ .../references/anti-patterns.md | 53 +++ .../references/examples.md | 357 ++++++++++++++++++ .../references/preferred-patterns.md | 57 +++ .gitattributes | 13 + .gitignore | 3 + AGENTS.md | 3 + CLAUDE.md | 2 + apm.lock.yaml | 113 ++++++ apm.yml | 15 + 18 files changed, 1709 insertions(+) create mode 100644 .agents/skills/feather-error-handling/SKILL.md create mode 100644 .agents/skills/feather-error-handling/references/anti-patterns.md create mode 100644 .agents/skills/feather-error-handling/references/examples.md create mode 100644 .agents/skills/feather-error-handling/references/preferred-patterns.md create mode 100644 .apm/skills/feather-error-handling/SKILL.md create mode 100644 .apm/skills/feather-error-handling/references/anti-patterns.md create mode 100644 .apm/skills/feather-error-handling/references/examples.md create mode 100644 .apm/skills/feather-error-handling/references/preferred-patterns.md create mode 100644 .claude/skills/feather-error-handling/SKILL.md create mode 100644 .claude/skills/feather-error-handling/references/anti-patterns.md create mode 100644 .claude/skills/feather-error-handling/references/examples.md create mode 100644 .claude/skills/feather-error-handling/references/preferred-patterns.md create mode 100644 .gitattributes create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 apm.lock.yaml create mode 100644 apm.yml diff --git a/.agents/skills/feather-error-handling/SKILL.md b/.agents/skills/feather-error-handling/SKILL.md new file mode 100644 index 0000000..e822e13 --- /dev/null +++ b/.agents/skills/feather-error-handling/SKILL.md @@ -0,0 +1,53 @@ +--- +name: feather-error-handling +description: Use whenever generating or reviewing F# code that handles errors with the Feather.ErrorHandling library — the `result {}`, `asyncResult {}`, `validation {}` (`let! ... and!`) or `maybe {}` computation expressions, the `AsyncResult<'T, 'E>` / `Validation<'T, 'F>` types, lifting Tasks and Asyncs with `AsyncResult.ofTaskCatch` / `ofEmptyTaskCatch` / `ofAsyncCatch` / `ofAsyncOption` / `ofBool`, combining lists via `Result.sequence`, `Validation.ofResults`, `AsyncResult.sequenceM` / `sequenceA` / `ofParallelAsyncResults`, side effects via `tee` / `teeError`, `AsyncResult.retry` / `retryWith`, `Async.withTimeout`, `Result.orFail`, or the `Result.Operators` / `AsyncResult.Operators` / `Option.Operators` symbols (`>>=`, `<@>`, `>=>`, ``). Trigger also on mentions of FeatherTools/error-handling, railway-oriented programming, or accumulating validation errors in F#. +--- + +# Feather-Error-Handling + +Library: [FeatherTools/error-handling](https://github.com/FeatherTools/error-handling) +NuGet: `Feather.ErrorHandling` + +## Purpose + +`Feather.ErrorHandling` extends F#'s `Option`, `Result` and `Async` with the functions and computation expressions needed for railway-oriented error handling: fail-fast `result {}`, applicative `validation {}` that accumulates failures, `asyncResult {}` for asynchronous workflows with typed errors, plus lifting from `Task`/`Async`/`Option`, list sequencing, parallel execution, retries and operator modules. It ships its sources for Fable. + +## When to Use + +- Writing domain logic where failures are values (`Result<'T, 'Error>`) instead of exceptions. +- Collecting every validation failure of a form, command or config record in one pass. +- Composing asynchronous steps that can fail, including .NET `Task` APIs whose exceptions must become typed errors. +- Running a list of fallible async operations in parallel, throttled or in sequence, with all errors collected. +- Retrying transient failures or bounding an async computation with a timeout. + +## When NOT to Use + +- The project already standardises on FsToolkit.ErrorHandling — the CE names (`result`, `asyncResult`, `validation`) collide. +- You need `task {}`-based workflows with typed errors — this library is `Async`-based; `Task` is only an input to lift. +- Exponential backoff, jitter or circuit breaking — `retry*` provide constant or linear waits only. + +## Main Concepts + +- **`result {}`** — fail-fast CE over `Result`; supports `let!`, `do!`, `return!`, `if … then return! Error e`, `for`, `while`, `try/with`, `use`. +- **`Validation<'T, 'F>`** — abbreviation for `Result<'T, 'F list>`; `Validation.apply` / `sequence` / `zip` accumulate failures; `ofResult`, `ofResults`, `toResult` convert. +- **`validation {}`** — applicative CE: `let! … and! …` evaluates every binding and concatenates failures; binds both `Result` and `Validation`; no sequential `let!`. +- **`AsyncResult<'T, 'E>`** — abbreviation for `Async>`; the `AsyncResult` module covers map/bind/tee, lifting (`ofSuccess`, `ofError`, `ofResult`, `ofAsync*`, `ofTask*`, `ofEmptyTask*`, `ofOption`, `ofAsyncOption`, `ofBool`), sequencing and parallel helpers. +- **`asyncResult {}`** — CE over `AsyncResult`; also binds `Result`, and binds `Async` / `Task` with error type `exn`. +- **`maybe {}`** — CE over `Option`. +- **`Result` / `Option` / `Async` modules** — additions to the FSharp.Core modules: `tee`, `teeError`, `bindError`, `ofOption`, `sequence`, `list`, `orFail`; `bindNone`, `mapNone`, `orDefault`, `ofNullable`; `Async.map`, `apply`, `withTimeout`. +- **Operator modules** — `Result.Operators`, `AsyncResult.Operators`, `Option.Operators`; opt-in, value-on-the-left (`r >>= f`, `r <@> f`), Kleisli `>=>`. +- **Retry** — `AsyncResult.retry`, `retryWith`, `retryWithExponential` (linear backoff). + +## Related Libraries + +- **FSharp.Core** — the only dependency; the library extends its `Result`, `Option` and `Async` modules. + +## Keywords for Search + +Feather.ErrorHandling, FeatherTools, error-handling, railway-oriented programming, Result, result CE, Validation, validation CE, and!, applicative, AsyncResult, asyncResult CE, maybe CE, Option, Async, tee, teeError, bindError, ofOption, sequence, sequenceM, sequenceA, Validation.ofResults, Validation.zip, orFail, ofTaskCatch, ofEmptyTaskCatch, ofAsyncCatch, ofAsyncOption, ofBool, ofParallelAsyncResults, ofMaxParallelAsyncResults, ofSequentialAsyncResults, retry, retryWith, retryWithExponential, withTimeout, Operators, >>=, >=>, <@>, , , , , Kleisli, Fable, F# + +## Reference Files + +- For composition principles, recommended API usage, error mapping and testing guidance, read `references/preferred-patterns.md`. +- For CE pitfalls, misleading names, exception leaks and operator gotchas, read `references/anti-patterns.md`. +- For worked, self-contained code examples ordered by increasing complexity, read `references/examples.md`. diff --git a/.agents/skills/feather-error-handling/references/anti-patterns.md b/.agents/skills/feather-error-handling/references/anti-patterns.md new file mode 100644 index 0000000..f389315 --- /dev/null +++ b/.agents/skills/feather-error-handling/references/anti-patterns.md @@ -0,0 +1,53 @@ +# Anti-Patterns + +Format: **mistake → why it is wrong → fix**. + +## Misleading Names and Doc Comments + +- **Expecting `AsyncResult.retryWithExponential` to back off exponentially** → it waits `currentAttempt * baseWaitMs` (linear: 10, 20, 30 ms …); the source comment on `RetryPolicy` says exponential backoff was rejected on purpose → use it for linear backoff and size `baseWaitMs` / `attempts` with that in mind. + +- **Trusting the "Run … in Parallel" doc comment on `AsyncResult.ofSequentialAsyncResults` / `ofSequentialAsyncs`** → both use `Async.Sequential` and run one computation at a time → use `ofParallelAsyncResults` / `ofMaxParallelAsyncResults` when you want concurrency. + +- **Expecting `AsyncResult.sequenceM` to stop executing after the first `Error`** → it returns only the first error, but every computation in the list still runs, in order → if later computations must not run after a failure, bind them one by one in `asyncResult {}`. + +- **Writing `AsyncResult.retry waitMs attempts` and expecting `attempts` total runs** → the computation runs once, then up to `attempts` more times (`retry 100 5` can run 6 times) → pass the number of *retries*, not total attempts. + +## Computation Expression Pitfalls + +- **Sequential `let!` in `validation {}`** → the builder has no `Bind`; two `let!` without `and!` does not compile (FS0708) → join independent checks with `and!`; sequence dependent stages by binding each `validation {}` in an outer `result {}`. See `examples.md` → Validation stages sequenced with `result {}`. + +- **Using `do!`, loops, `try` or `if` without `else` inside `validation {}`** → the builder defines only `Return`, `ReturnFrom`, `BindReturn`, `MergeSources` and `Source`, so these do not compile → move the logic into a `result {}` or a function returning `Result` / `Validation`, then bind it. + +- **Expecting an inner `result {}` bound with `and!` to report several failures** → `result {}` stops at its first error, so it contributes one failure → split independent checks into separate `and!` bindings. + +- **Binding `Async<'T>` or `Task<'T>` inside `asyncResult {}` whose error type is a domain DU** → those overloads fix the error type to `exn`, so the CE fails to type-check → lift first: `let! x = work () |> AsyncResult.ofAsyncCatch MyError.Failed` (or `ofTaskCatch` / `ofEmptyTaskCatch`). + +- **Calling a method or property on a value bound from an `AsyncResult` in `asyncResult {}` without a type annotation** (e.g. `let! content = readText path` then `content.ToUpperInvariant()`) → the `AsyncResult` and `Async<'T>` `Bind` overloads both match, so the type is unknown at the member lookup (FS0072) → annotate the binding: `let! (content: string) = readText path`. Record field access and passing the value to a typed function are unaffected. + +## Exceptions and Interop + +- **Using `AsyncResult.ofTask` / `ofAsync` / `ofEmptyTask` for code that can throw** → these do not catch; the exception escapes when the workflow runs → use `ofTaskCatch` / `ofAsyncCatch` / `ofEmptyTaskCatch` with an `exn -> 'Error` mapper. + +- **Matching the exception type directly in an `ofTaskCatch` handler** → a faulted task reaches the handler as `AggregateException`, so `:? IOException` never matches → unwrap `InnerException` first. See `examples.md` → Integration. + +- **Relying on `AsyncResult.ofAsyncOption` to surface exceptions** → an exception in the `Async<'T option>` is mapped to the same `onMissing` error as `None` → if the two must differ, lift with `ofAsyncCatch` and then `Result.ofOption` / `AsyncResult.ofOption` separately. + +- **Relying on `Async.withTimeout` only for timeouts** → it returns `notInTimeValue` for *any* exception from the computation, not just the timeout → catch domain exceptions inside the computation (e.g. `AsyncResult.catch`) before applying the timeout. + +- **Retrying a computation built from an already-started `Task`** → `retry` re-runs the same `Async`, which re-awaits the same completed task and gets the same result → build the computation lazily, e.g. `asyncResult { return! createTask () |> AsyncResult.ofTaskCatch f }`. + +- **`Result.orFail` in request handlers, consumers or pure logic** → it throws `failwithf "Error %A"` and loses the typed error → propagate the `Result`; reserve `orFail` for startup/configuration code and tests. + +## Operators + +- **Writing `f xR <*> yR` (Haskell style)** → in `Result.Operators` both are flipped: `r f` is `Result.map f r` and `xR <*> fR` is `Result.apply fR xR`; applicative-style construction does not type-check → use `result {}` / `validation {}`, or `Result.apply` / `Validation.apply` directly. + +- **Opening `Result.Operators`, `AsyncResult.Operators` and `Option.Operators` in one file** → they define the same symbols and the last `open` shadows the others; `<@>` is `mapError` for `Result` / `AsyncResult` but `mapNone` for `Option` → open only one operator module per scope, or use the named functions. + +## Silently Dropped Errors + +- **Using `Result.list` / `Result.listCollect` when errors matter** → they keep successes and discard every `Error` → use `Result.sequence` (first error) or `Validation.ofResults` (all errors), or log with `Result.teeError` before filtering. + +- **Using `Result.sequence` to report all invalid inputs** → it is monadic and returns only the first error → use `Validation.sequence` / `Validation.ofResults`. + +- **Ignoring a returned `Result` with `|> ignore`** → the failure disappears → bind it with `do!` / `let!`, or handle it with `Result.iter` / `Result.teeError` explicitly. diff --git a/.agents/skills/feather-error-handling/references/examples.md b/.agents/skills/feather-error-handling/references/examples.md new file mode 100644 index 0000000..516519d --- /dev/null +++ b/.agents/skills/feather-error-handling/references/examples.md @@ -0,0 +1,357 @@ +# Examples + +All example code for this skill lives here. Examples are ordered by increasing complexity and each is self-contained. Neutral placeholder names are used throughout. + +## Basic — `result {}` with a typed error + +```fs +open Feather.ErrorHandling + +[] +type ParseError = + | Empty + | NotANumber of string + | Negative of int + +let parseQuantity (raw: string): Result = + result { + let! raw = if raw = "" then Error ParseError.Empty else Ok raw + + let! quantity = + (match System.Int32.TryParse raw with + | true, value -> Some value + | _ -> None) + |> Result.ofOption (ParseError.NotANumber raw) + + if quantity < 0 then return! Error (ParseError.Negative quantity) + + return quantity + } + +parseQuantity "42" // Ok 42 +parseQuantity "abc" // Error (NotANumber "abc") +parseQuantity "-1" // Error (Negative -1) +``` + +## Side effects without breaking the chain + +`tee` / `teeError` run an effect and pass the value through unchanged. + +```fs +open Feather.ErrorHandling + +let loadSetting (key: string): Result = + if key = "endpoint" then Ok "https://example.test" else Error $"missing setting {key}" + +[ "endpoint"; "timeout" ] +|> List.map ( + loadSetting + >> Result.tee (printfn "loaded: %s") + >> Result.teeError (eprintfn "warning: %s") +) +|> Result.list // [ "https://example.test" ] — errors are dropped, not reported +``` + +## Option helpers and `maybe {}` + +```fs +open Feather.ErrorHandling +open Feather.ErrorHandling.Option.Operators + +let tryFindPort (name: string): int option = + if name = "api" then Some 8080 else None + +let address = + maybe { + let! port = tryFindPort "api" + return $"localhost:{port}" + } + +let port = tryFindPort "worker" 80 // 80 +let fallback = tryFindPort "worker" tryFindPort "api" // Some 8080 +let required = tryFindPort "worker" "port not set" // Error "port not set" + +let lazyDefault = tryFindPort "worker" |> Option.orDefault (fun () -> 80) +let nullable = System.Nullable 3 |> Option.ofNullable // Some 3 +``` + +## Validation — accumulating failures + +`let! ... and! ...` evaluates every binding and concatenates all failures. A `Result` binding is lifted to a one-element failure list; an inner `result {}` contributes at most one failure. + +```fs +open Feather.ErrorHandling + +type Account = { Name: string; Age: int; Email: string } + +[] +type AccountError = + | NameEmpty + | AgeInvalid of string + | AgeTooLow of int + | EmailInvalid of string + +let validateName name = + if name = "" then Error AccountError.NameEmpty else Ok name + +let validateEmail (email: string) = + if email.Contains "@" then Ok email else Error (AccountError.EmailInvalid email) + +let validateAge (raw: string) = + result { + let! age = + match System.Int32.TryParse raw with + | true, value -> Ok value + | _ -> Error (AccountError.AgeInvalid raw) + + if age < 18 then return! Error (AccountError.AgeTooLow age) + + return age + } + +let createAccount name age email: Validation = + validation { + let! name = validateName name + and! age = validateAge age + and! email = validateEmail email + + return { Name = name; Age = age; Email = email } + } + +createAccount "alice" "30" "alice@example.test" // Ok { ... } +createAccount "" "16" "nope" +// Error [ NameEmpty; AgeTooLow 16; EmailInvalid "nope" ] +``` + +## Validation stages sequenced with `result {}` + +`validation {}` has no sequential `let!`. A `Validation` is a `Result<_, _ list>`, so bind whole stages in an outer `result {}`: the second stage runs only when the first succeeded. + +```fs +open Feather.ErrorHandling + +[] +type OrderError = + | SkuEmpty + | QuantityNotPositive of int + | OutOfStock of string + +let checkStock (sku: string) (quantity: int) = + if sku = "sold-out" then Error (OrderError.OutOfStock sku) else Ok (sku, quantity) + +let placeOrder (sku: string) (quantity: int): Validation = + result { + let! sku, quantity = + validation { + let! sku = if sku = "" then Error OrderError.SkuEmpty else Ok sku + and! quantity = if quantity <= 0 then Error (OrderError.QuantityNotPositive quantity) else Ok quantity + + return sku, quantity + } + + return! checkStock sku quantity |> Validation.ofResult + } + +placeOrder "" 0 // Error [ SkuEmpty; QuantityNotPositive 0 ] — stock never checked +placeOrder "sold-out" 1 // Error [ OutOfStock "sold-out" ] +``` + +## Realistic — `asyncResult {}` workflow + +Mirrors the library's login test: lift `Async<'T option>`, `bool`, `AsyncResult` and `Result` into one workflow, mapping each inner error into the workflow's error type. + +```fs +open Feather.ErrorHandling +open Feather.ErrorHandling.AsyncResult.Operators + +type User = { Username: string; Password: string } +type Token = Token of string + +type AuthError = AuthError of string +type TokenError = TokenError of string + +[] +type LoginError = + | InvalidUser + | InvalidPassword + | Unauthorized of AuthError + | TokenFailed of TokenError + +let login + (tryGetUser: string -> Async) + (authorize: User -> AsyncResult) + (createToken: User -> Result) + (username: string, password: string): AsyncResult = + + asyncResult { + let! user = username |> tryGetUser |> AsyncResult.ofAsyncOption LoginError.InvalidUser + do! user.Password = password |> AsyncResult.ofBool LoginError.InvalidPassword + do! user |> authorize <@> LoginError.Unauthorized + + return! user |> createToken |> Result.mapError LoginError.TokenFailed + } +``` + +## Integration — Task-based .NET APIs + +Lift every `Task` at the boundary with a `*Catch` variant so exceptions become a typed error. A faulted `Task` arrives in the handler as an `AggregateException`; unwrap it when the inner exception matters. + +```fs +open System +open System.IO +open System.Threading.Tasks +open Feather.ErrorHandling + +[] +type StorageError = + | ReadFailed of path: string * exn + | WriteFailed of path: string * exn + +let private unwrap: exn -> exn = function + | :? AggregateException as e when not (isNull e.InnerException) -> e.InnerException + | e -> e + +let readText (path: string): AsyncResult = + File.ReadAllTextAsync path + |> AsyncResult.ofTaskCatch (fun e -> StorageError.ReadFailed (path, unwrap e)) + +let writeText (path: string) (content: string): AsyncResult = + File.WriteAllTextAsync(path, content) + |> AsyncResult.ofEmptyTaskCatch (fun e -> StorageError.WriteFailed (path, unwrap e)) + +let copyUpper (source: string) (target: string): AsyncResult = + asyncResult { + let! (content: string) = readText source + do! content.ToUpperInvariant() |> writeText target + } +``` + +## Kleisli composition with operators + +```fs +open Feather.ErrorHandling +open Feather.ErrorHandling.Result.Operators + +[] +type PipelineError = + | Empty + | TooLong of int + +let notEmpty (s: string) = if s = "" then Error PipelineError.Empty else Ok s +let maxLength limit (s: string) = if s.Length > limit then Error (PipelineError.TooLong s.Length) else Ok s + +let toLower (s: string) = s.ToLowerInvariant() + +let validateSlug: string -> Result = + notEmpty + >=> maxLength 20 + >!> toLower + >@> sprintf "%A" + +let slugLength = Ok "Hello" >>= notEmpty String.length // Ok 5 +``` + +## Collections of results + +```fs +open Feather.ErrorHandling + +let parse (raw: string): Result = + match System.Int32.TryParse raw with + | true, value -> Ok value + | _ -> Error $"not a number: {raw}" + +let inputs = [ "1"; "x"; "3"; "y" ] + +inputs |> List.map parse |> Result.sequence // Error "not a number: x" +inputs |> List.map parse |> Validation.ofResults // Error [ "not a number: x"; "not a number: y" ] +inputs |> List.map parse |> Result.list // [ 1; 3 ] +``` + +## Parallel asynchronous work + +`ofParallelAsyncResults` runs every computation and accumulates every `Error`; an exception thrown by any of them becomes the single error from the handler. + +```fs +open Feather.ErrorHandling + +let fetch (id: int): AsyncResult = + asyncResult { + do! AsyncResult.sleep 10 + if id % 2 = 0 then return! Error $"item {id} unavailable" + return $"item {id}" + } + +let all: AsyncResult = + [ 1 .. 5 ] + |> List.map fetch + |> AsyncResult.ofParallelAsyncResults (fun e -> e.Message) +// Error [ "item 2 unavailable"; "item 4 unavailable" ] + +let throttled: AsyncResult = + [ 1; 3; 5 ] + |> List.map fetch + |> AsyncResult.ofMaxParallelAsyncResults 2 (fun e -> e.Message) +// Ok [ "item 1"; "item 3"; "item 5" ] +``` + +## Retry and timeout + +`retryWith log waitMs attempts` runs the computation once and then up to `attempts` more times while it returns `Error`. The computation must be cold (re-executed on each run). + +```fs +open Feather.ErrorHandling + +let mutable calls = 0 + +let flaky: AsyncResult = + asyncResult { + calls <- calls + 1 + if calls < 3 then return! Error $"attempt {calls} failed" + return "done" + } + +let withRetry = + flaky + |> AsyncResult.retryWith (printfn "%s") 100 5 +// prints "Retrying [1/5] in 100ms ..." and "Retrying [2/5] in 100ms ...", then Ok "done" + +let bounded: Async> = + flaky + |> Async.withTimeout 500 (Error "timed out") +``` + +## Test — asserting on workflows with Expecto + +```fs +module Example.WorkflowTest + +open Expecto +open Feather.ErrorHandling + +let private run (workflow: AsyncResult<'T, 'E>) = workflow |> Async.RunSynchronously + +[] +let tests = + testList "Workflows" [ + testCase "should continue with the bound value when the Result is Ok" <| fun _ -> + let actual = + asyncResult { + let! quantity = Ok 3 + return quantity * 2 + } + |> run + + Expect.equal actual (Ok 6) "Should double the bound value" + + testCase "should accumulate every failure when several fields are invalid" <| fun _ -> + let actual: Validation = + validation { + let! a = Error "a" + and! b = Error "b" + return a, b + } + + Expect.equal actual (Error [ "a"; "b" ]) "Should keep failures in binding order" + ] +``` diff --git a/.agents/skills/feather-error-handling/references/preferred-patterns.md b/.agents/skills/feather-error-handling/references/preferred-patterns.md new file mode 100644 index 0000000..7bf2573 --- /dev/null +++ b/.agents/skills/feather-error-handling/references/preferred-patterns.md @@ -0,0 +1,57 @@ +# Preferred Patterns + +## Core Principles + +- Model expected failures as `Result<'T, 'Error>` with a domain error DU, not as exceptions. Exceptions enter the error track only at the boundary, through a `*Catch` lift or `AsyncResult.catch`. +- Pick the shape by failure semantics: `Result` / `result {}` stops at the first error; `Validation` / `validation {}` collects every error; `AsyncResult` / `asyncResult {}` is `Result` inside `Async`. +- `Validation<'T, 'F>` and `AsyncResult<'T, 'E>` are type abbreviations (`Result<'T, 'F list>` and `Async>`), so standard `Result` / `Async` functions apply to them unchanged. +- Prefer `Async` over `Task`. Lift a `Task` into `AsyncResult` as soon as it is created and keep `Task` out of your own signatures. + +## Recommended API Usage + +- Sequential, fail-fast logic: `result {}` with `let!` / `do!` / `return!`; `if cond then return! Error e` works as an early exit. See `examples.md` → Basic. +- Convert at the edges: `Result.ofOption error`, `Result.ofChoice` / `toChoice`, `Result.toOption`, `Option.ofNullable` / `toNullable`, `AsyncResult.ofBool error`. +- Side effects mid-pipeline: `Result.tee` / `teeError`, `AsyncResult.tee` / `teeError`, `Option.tee` / `teeNone`, `Async.tee`. See `examples.md` → Side effects without breaking the chain. +- Error recovery: `Result.bindError` / `AsyncResult.bindError` (error → new `Result`), `Option.bindNone` / `mapNone` / `orDefault` (all take a thunk). +- Collect independent field checks: `validation { let! a = ... and! b = ... return ... }`. Both `Result` and `Validation` values can be bound; a `Result` failure is lifted to a one-item list. See `examples.md` → Validation — accumulating failures. +- Staged validation: bind each `validation {}` stage inside an outer `result {}`. See `examples.md` → Validation stages sequenced with `result {}`. +- Async workflows: `asyncResult {}` binds `AsyncResult`, `Result` (and so `Validation`), and — only when the error type is `exn` — `Async` and `Task`. See `examples.md` → Realistic. +- Task / Async interop: `AsyncResult.ofTaskCatch`, `ofEmptyTaskCatch` (non-generic `Task`), `ofAsyncCatch`, each taking an `exn -> 'Error` mapper. See `examples.md` → Integration. +- Lists: `Result.sequence` (first error), `Validation.sequence` / `Validation.ofResults` (all errors), `Result.sequenceConcat`, `Result.list` / `listCollect` (keep successes only). See `examples.md` → Collections of results. +- Concurrent async work: `AsyncResult.ofParallelAsyncResults`, `ofMaxParallelAsyncResults maxParallel`, `ofSequentialAsyncResults`, and the `*Asyncs` variants for plain `Async<'T>`. See `examples.md` → Parallel asynchronous work. +- Transient failures: `AsyncResult.retry waitMs attempts`, `retryWith log waitMs attempts`, `retryWithExponential log baseWaitMs attempts`; time limits with `Async.withTimeout ms fallback`. See `examples.md` → Retry and timeout. + +## Error Handling + +- One error DU per operation; wrap inner errors in a case of the outer DU and lift with `Result.mapError` / `AsyncResult.mapError` (or `<@>`) at the bind site: `do! authorize user <@> LoginError.Unauthorized`. +- Exception handlers passed to `ofTaskCatch` / `ofEmptyTaskCatch` receive the `AggregateException` from a faulted task; unwrap `InnerException` when the cause matters. `ofAsyncCatch` receives the original exception. +- `Result.orFail` throws (`failwithf "Error %A"`). Use it only where no caller can receive an error: startup, configuration, tests. +- Inside `result {}` and `asyncResult {}`, `try ... with e -> return! Error ...` turns an exception from that block into an error. + +## Composition + +- Kleisli `>=>` composes `'a -> Result<'b, 'e>` functions; `>!>` maps the success, `>@>` maps the error, `>@*>` / `>@@>` tee. `>->` composes error handlers. See `examples.md` → Kleisli composition with operators. +- Operator modules are opt-in: `open Feather.ErrorHandling.Result.Operators`, `AsyncResult.Operators`, or `Option.Operators`. Open only the one the file works in; the same symbols mean different things in each. +- In the operator modules the value is on the left and the function on the right: `r >>= f`, `r f`, `r <@> f`, and `rValue <*> rFunction`. +- `Option.Operators` adds `opt default`, `opt otherOpt`, `opt error` (to `Result`), and `opt |>! action` (`Option.iter`). +- `Async.apply` starts the function computation as a child and runs both in parallel; `Async.map` / `bind` / `retn` / `tee` cover plain `Async`. + +## Integration with Other Libraries + +- The package ships its `.fs` sources under `fable/`, so it can be referenced from Fable projects. +- The CEs (`result`, `asyncResult`, `validation`, `maybe`) are `[]`; `open Feather.ErrorHandling` brings them in. Avoid opening another error-handling library that defines builders with the same names (e.g. FsToolkit.ErrorHandling) in the same file. +- The `Result`, `Option`, `Async` and `AsyncResult` modules are `[]` and extend the FSharp.Core modules of the same name: `Result.map` and `Result.tee` resolve side by side. + +## Naming Conventions + +- `of*` lifts into the type (`ofOption`, `ofTask`, `ofResult`); `to*` converts out (`toOption`, `toChoice`, `toNullable`). +- `*Catch` variants turn exceptions into errors; the plain variant (`ofTask`, `ofAsync`, `ofEmptyTask`) lets them propagate. +- `sequenceM` is monadic (first error, any error type); `sequenceA` is applicative (all errors, error type must be a list). +- `retn` lifts a value (`Async.retn`, `Option.retn`, `AsyncResult.retn` = `ofSuccess`). + +## Testing Recommendations + +- Assert on the full `Result` value (`Expect.equal actual (Error [ ... ]) ...`) so the error case and its payload are both checked. See `examples.md` → Test. +- Run `AsyncResult` in tests with `Async.RunSynchronously`; keep it out of production code paths. +- For `validation {}`, assert the order of accumulated failures — it follows binding order. +- Test retries with a counter captured by a cold `asyncResult {}` and a small `waitMs`, as the library's own retry tests do. diff --git a/.apm/skills/feather-error-handling/SKILL.md b/.apm/skills/feather-error-handling/SKILL.md new file mode 100644 index 0000000..e822e13 --- /dev/null +++ b/.apm/skills/feather-error-handling/SKILL.md @@ -0,0 +1,53 @@ +--- +name: feather-error-handling +description: Use whenever generating or reviewing F# code that handles errors with the Feather.ErrorHandling library — the `result {}`, `asyncResult {}`, `validation {}` (`let! ... and!`) or `maybe {}` computation expressions, the `AsyncResult<'T, 'E>` / `Validation<'T, 'F>` types, lifting Tasks and Asyncs with `AsyncResult.ofTaskCatch` / `ofEmptyTaskCatch` / `ofAsyncCatch` / `ofAsyncOption` / `ofBool`, combining lists via `Result.sequence`, `Validation.ofResults`, `AsyncResult.sequenceM` / `sequenceA` / `ofParallelAsyncResults`, side effects via `tee` / `teeError`, `AsyncResult.retry` / `retryWith`, `Async.withTimeout`, `Result.orFail`, or the `Result.Operators` / `AsyncResult.Operators` / `Option.Operators` symbols (`>>=`, `<@>`, `>=>`, ``). Trigger also on mentions of FeatherTools/error-handling, railway-oriented programming, or accumulating validation errors in F#. +--- + +# Feather-Error-Handling + +Library: [FeatherTools/error-handling](https://github.com/FeatherTools/error-handling) +NuGet: `Feather.ErrorHandling` + +## Purpose + +`Feather.ErrorHandling` extends F#'s `Option`, `Result` and `Async` with the functions and computation expressions needed for railway-oriented error handling: fail-fast `result {}`, applicative `validation {}` that accumulates failures, `asyncResult {}` for asynchronous workflows with typed errors, plus lifting from `Task`/`Async`/`Option`, list sequencing, parallel execution, retries and operator modules. It ships its sources for Fable. + +## When to Use + +- Writing domain logic where failures are values (`Result<'T, 'Error>`) instead of exceptions. +- Collecting every validation failure of a form, command or config record in one pass. +- Composing asynchronous steps that can fail, including .NET `Task` APIs whose exceptions must become typed errors. +- Running a list of fallible async operations in parallel, throttled or in sequence, with all errors collected. +- Retrying transient failures or bounding an async computation with a timeout. + +## When NOT to Use + +- The project already standardises on FsToolkit.ErrorHandling — the CE names (`result`, `asyncResult`, `validation`) collide. +- You need `task {}`-based workflows with typed errors — this library is `Async`-based; `Task` is only an input to lift. +- Exponential backoff, jitter or circuit breaking — `retry*` provide constant or linear waits only. + +## Main Concepts + +- **`result {}`** — fail-fast CE over `Result`; supports `let!`, `do!`, `return!`, `if … then return! Error e`, `for`, `while`, `try/with`, `use`. +- **`Validation<'T, 'F>`** — abbreviation for `Result<'T, 'F list>`; `Validation.apply` / `sequence` / `zip` accumulate failures; `ofResult`, `ofResults`, `toResult` convert. +- **`validation {}`** — applicative CE: `let! … and! …` evaluates every binding and concatenates failures; binds both `Result` and `Validation`; no sequential `let!`. +- **`AsyncResult<'T, 'E>`** — abbreviation for `Async>`; the `AsyncResult` module covers map/bind/tee, lifting (`ofSuccess`, `ofError`, `ofResult`, `ofAsync*`, `ofTask*`, `ofEmptyTask*`, `ofOption`, `ofAsyncOption`, `ofBool`), sequencing and parallel helpers. +- **`asyncResult {}`** — CE over `AsyncResult`; also binds `Result`, and binds `Async` / `Task` with error type `exn`. +- **`maybe {}`** — CE over `Option`. +- **`Result` / `Option` / `Async` modules** — additions to the FSharp.Core modules: `tee`, `teeError`, `bindError`, `ofOption`, `sequence`, `list`, `orFail`; `bindNone`, `mapNone`, `orDefault`, `ofNullable`; `Async.map`, `apply`, `withTimeout`. +- **Operator modules** — `Result.Operators`, `AsyncResult.Operators`, `Option.Operators`; opt-in, value-on-the-left (`r >>= f`, `r <@> f`), Kleisli `>=>`. +- **Retry** — `AsyncResult.retry`, `retryWith`, `retryWithExponential` (linear backoff). + +## Related Libraries + +- **FSharp.Core** — the only dependency; the library extends its `Result`, `Option` and `Async` modules. + +## Keywords for Search + +Feather.ErrorHandling, FeatherTools, error-handling, railway-oriented programming, Result, result CE, Validation, validation CE, and!, applicative, AsyncResult, asyncResult CE, maybe CE, Option, Async, tee, teeError, bindError, ofOption, sequence, sequenceM, sequenceA, Validation.ofResults, Validation.zip, orFail, ofTaskCatch, ofEmptyTaskCatch, ofAsyncCatch, ofAsyncOption, ofBool, ofParallelAsyncResults, ofMaxParallelAsyncResults, ofSequentialAsyncResults, retry, retryWith, retryWithExponential, withTimeout, Operators, >>=, >=>, <@>, , , , , Kleisli, Fable, F# + +## Reference Files + +- For composition principles, recommended API usage, error mapping and testing guidance, read `references/preferred-patterns.md`. +- For CE pitfalls, misleading names, exception leaks and operator gotchas, read `references/anti-patterns.md`. +- For worked, self-contained code examples ordered by increasing complexity, read `references/examples.md`. diff --git a/.apm/skills/feather-error-handling/references/anti-patterns.md b/.apm/skills/feather-error-handling/references/anti-patterns.md new file mode 100644 index 0000000..f389315 --- /dev/null +++ b/.apm/skills/feather-error-handling/references/anti-patterns.md @@ -0,0 +1,53 @@ +# Anti-Patterns + +Format: **mistake → why it is wrong → fix**. + +## Misleading Names and Doc Comments + +- **Expecting `AsyncResult.retryWithExponential` to back off exponentially** → it waits `currentAttempt * baseWaitMs` (linear: 10, 20, 30 ms …); the source comment on `RetryPolicy` says exponential backoff was rejected on purpose → use it for linear backoff and size `baseWaitMs` / `attempts` with that in mind. + +- **Trusting the "Run … in Parallel" doc comment on `AsyncResult.ofSequentialAsyncResults` / `ofSequentialAsyncs`** → both use `Async.Sequential` and run one computation at a time → use `ofParallelAsyncResults` / `ofMaxParallelAsyncResults` when you want concurrency. + +- **Expecting `AsyncResult.sequenceM` to stop executing after the first `Error`** → it returns only the first error, but every computation in the list still runs, in order → if later computations must not run after a failure, bind them one by one in `asyncResult {}`. + +- **Writing `AsyncResult.retry waitMs attempts` and expecting `attempts` total runs** → the computation runs once, then up to `attempts` more times (`retry 100 5` can run 6 times) → pass the number of *retries*, not total attempts. + +## Computation Expression Pitfalls + +- **Sequential `let!` in `validation {}`** → the builder has no `Bind`; two `let!` without `and!` does not compile (FS0708) → join independent checks with `and!`; sequence dependent stages by binding each `validation {}` in an outer `result {}`. See `examples.md` → Validation stages sequenced with `result {}`. + +- **Using `do!`, loops, `try` or `if` without `else` inside `validation {}`** → the builder defines only `Return`, `ReturnFrom`, `BindReturn`, `MergeSources` and `Source`, so these do not compile → move the logic into a `result {}` or a function returning `Result` / `Validation`, then bind it. + +- **Expecting an inner `result {}` bound with `and!` to report several failures** → `result {}` stops at its first error, so it contributes one failure → split independent checks into separate `and!` bindings. + +- **Binding `Async<'T>` or `Task<'T>` inside `asyncResult {}` whose error type is a domain DU** → those overloads fix the error type to `exn`, so the CE fails to type-check → lift first: `let! x = work () |> AsyncResult.ofAsyncCatch MyError.Failed` (or `ofTaskCatch` / `ofEmptyTaskCatch`). + +- **Calling a method or property on a value bound from an `AsyncResult` in `asyncResult {}` without a type annotation** (e.g. `let! content = readText path` then `content.ToUpperInvariant()`) → the `AsyncResult` and `Async<'T>` `Bind` overloads both match, so the type is unknown at the member lookup (FS0072) → annotate the binding: `let! (content: string) = readText path`. Record field access and passing the value to a typed function are unaffected. + +## Exceptions and Interop + +- **Using `AsyncResult.ofTask` / `ofAsync` / `ofEmptyTask` for code that can throw** → these do not catch; the exception escapes when the workflow runs → use `ofTaskCatch` / `ofAsyncCatch` / `ofEmptyTaskCatch` with an `exn -> 'Error` mapper. + +- **Matching the exception type directly in an `ofTaskCatch` handler** → a faulted task reaches the handler as `AggregateException`, so `:? IOException` never matches → unwrap `InnerException` first. See `examples.md` → Integration. + +- **Relying on `AsyncResult.ofAsyncOption` to surface exceptions** → an exception in the `Async<'T option>` is mapped to the same `onMissing` error as `None` → if the two must differ, lift with `ofAsyncCatch` and then `Result.ofOption` / `AsyncResult.ofOption` separately. + +- **Relying on `Async.withTimeout` only for timeouts** → it returns `notInTimeValue` for *any* exception from the computation, not just the timeout → catch domain exceptions inside the computation (e.g. `AsyncResult.catch`) before applying the timeout. + +- **Retrying a computation built from an already-started `Task`** → `retry` re-runs the same `Async`, which re-awaits the same completed task and gets the same result → build the computation lazily, e.g. `asyncResult { return! createTask () |> AsyncResult.ofTaskCatch f }`. + +- **`Result.orFail` in request handlers, consumers or pure logic** → it throws `failwithf "Error %A"` and loses the typed error → propagate the `Result`; reserve `orFail` for startup/configuration code and tests. + +## Operators + +- **Writing `f xR <*> yR` (Haskell style)** → in `Result.Operators` both are flipped: `r f` is `Result.map f r` and `xR <*> fR` is `Result.apply fR xR`; applicative-style construction does not type-check → use `result {}` / `validation {}`, or `Result.apply` / `Validation.apply` directly. + +- **Opening `Result.Operators`, `AsyncResult.Operators` and `Option.Operators` in one file** → they define the same symbols and the last `open` shadows the others; `<@>` is `mapError` for `Result` / `AsyncResult` but `mapNone` for `Option` → open only one operator module per scope, or use the named functions. + +## Silently Dropped Errors + +- **Using `Result.list` / `Result.listCollect` when errors matter** → they keep successes and discard every `Error` → use `Result.sequence` (first error) or `Validation.ofResults` (all errors), or log with `Result.teeError` before filtering. + +- **Using `Result.sequence` to report all invalid inputs** → it is monadic and returns only the first error → use `Validation.sequence` / `Validation.ofResults`. + +- **Ignoring a returned `Result` with `|> ignore`** → the failure disappears → bind it with `do!` / `let!`, or handle it with `Result.iter` / `Result.teeError` explicitly. diff --git a/.apm/skills/feather-error-handling/references/examples.md b/.apm/skills/feather-error-handling/references/examples.md new file mode 100644 index 0000000..516519d --- /dev/null +++ b/.apm/skills/feather-error-handling/references/examples.md @@ -0,0 +1,357 @@ +# Examples + +All example code for this skill lives here. Examples are ordered by increasing complexity and each is self-contained. Neutral placeholder names are used throughout. + +## Basic — `result {}` with a typed error + +```fs +open Feather.ErrorHandling + +[] +type ParseError = + | Empty + | NotANumber of string + | Negative of int + +let parseQuantity (raw: string): Result = + result { + let! raw = if raw = "" then Error ParseError.Empty else Ok raw + + let! quantity = + (match System.Int32.TryParse raw with + | true, value -> Some value + | _ -> None) + |> Result.ofOption (ParseError.NotANumber raw) + + if quantity < 0 then return! Error (ParseError.Negative quantity) + + return quantity + } + +parseQuantity "42" // Ok 42 +parseQuantity "abc" // Error (NotANumber "abc") +parseQuantity "-1" // Error (Negative -1) +``` + +## Side effects without breaking the chain + +`tee` / `teeError` run an effect and pass the value through unchanged. + +```fs +open Feather.ErrorHandling + +let loadSetting (key: string): Result = + if key = "endpoint" then Ok "https://example.test" else Error $"missing setting {key}" + +[ "endpoint"; "timeout" ] +|> List.map ( + loadSetting + >> Result.tee (printfn "loaded: %s") + >> Result.teeError (eprintfn "warning: %s") +) +|> Result.list // [ "https://example.test" ] — errors are dropped, not reported +``` + +## Option helpers and `maybe {}` + +```fs +open Feather.ErrorHandling +open Feather.ErrorHandling.Option.Operators + +let tryFindPort (name: string): int option = + if name = "api" then Some 8080 else None + +let address = + maybe { + let! port = tryFindPort "api" + return $"localhost:{port}" + } + +let port = tryFindPort "worker" 80 // 80 +let fallback = tryFindPort "worker" tryFindPort "api" // Some 8080 +let required = tryFindPort "worker" "port not set" // Error "port not set" + +let lazyDefault = tryFindPort "worker" |> Option.orDefault (fun () -> 80) +let nullable = System.Nullable 3 |> Option.ofNullable // Some 3 +``` + +## Validation — accumulating failures + +`let! ... and! ...` evaluates every binding and concatenates all failures. A `Result` binding is lifted to a one-element failure list; an inner `result {}` contributes at most one failure. + +```fs +open Feather.ErrorHandling + +type Account = { Name: string; Age: int; Email: string } + +[] +type AccountError = + | NameEmpty + | AgeInvalid of string + | AgeTooLow of int + | EmailInvalid of string + +let validateName name = + if name = "" then Error AccountError.NameEmpty else Ok name + +let validateEmail (email: string) = + if email.Contains "@" then Ok email else Error (AccountError.EmailInvalid email) + +let validateAge (raw: string) = + result { + let! age = + match System.Int32.TryParse raw with + | true, value -> Ok value + | _ -> Error (AccountError.AgeInvalid raw) + + if age < 18 then return! Error (AccountError.AgeTooLow age) + + return age + } + +let createAccount name age email: Validation = + validation { + let! name = validateName name + and! age = validateAge age + and! email = validateEmail email + + return { Name = name; Age = age; Email = email } + } + +createAccount "alice" "30" "alice@example.test" // Ok { ... } +createAccount "" "16" "nope" +// Error [ NameEmpty; AgeTooLow 16; EmailInvalid "nope" ] +``` + +## Validation stages sequenced with `result {}` + +`validation {}` has no sequential `let!`. A `Validation` is a `Result<_, _ list>`, so bind whole stages in an outer `result {}`: the second stage runs only when the first succeeded. + +```fs +open Feather.ErrorHandling + +[] +type OrderError = + | SkuEmpty + | QuantityNotPositive of int + | OutOfStock of string + +let checkStock (sku: string) (quantity: int) = + if sku = "sold-out" then Error (OrderError.OutOfStock sku) else Ok (sku, quantity) + +let placeOrder (sku: string) (quantity: int): Validation = + result { + let! sku, quantity = + validation { + let! sku = if sku = "" then Error OrderError.SkuEmpty else Ok sku + and! quantity = if quantity <= 0 then Error (OrderError.QuantityNotPositive quantity) else Ok quantity + + return sku, quantity + } + + return! checkStock sku quantity |> Validation.ofResult + } + +placeOrder "" 0 // Error [ SkuEmpty; QuantityNotPositive 0 ] — stock never checked +placeOrder "sold-out" 1 // Error [ OutOfStock "sold-out" ] +``` + +## Realistic — `asyncResult {}` workflow + +Mirrors the library's login test: lift `Async<'T option>`, `bool`, `AsyncResult` and `Result` into one workflow, mapping each inner error into the workflow's error type. + +```fs +open Feather.ErrorHandling +open Feather.ErrorHandling.AsyncResult.Operators + +type User = { Username: string; Password: string } +type Token = Token of string + +type AuthError = AuthError of string +type TokenError = TokenError of string + +[] +type LoginError = + | InvalidUser + | InvalidPassword + | Unauthorized of AuthError + | TokenFailed of TokenError + +let login + (tryGetUser: string -> Async) + (authorize: User -> AsyncResult) + (createToken: User -> Result) + (username: string, password: string): AsyncResult = + + asyncResult { + let! user = username |> tryGetUser |> AsyncResult.ofAsyncOption LoginError.InvalidUser + do! user.Password = password |> AsyncResult.ofBool LoginError.InvalidPassword + do! user |> authorize <@> LoginError.Unauthorized + + return! user |> createToken |> Result.mapError LoginError.TokenFailed + } +``` + +## Integration — Task-based .NET APIs + +Lift every `Task` at the boundary with a `*Catch` variant so exceptions become a typed error. A faulted `Task` arrives in the handler as an `AggregateException`; unwrap it when the inner exception matters. + +```fs +open System +open System.IO +open System.Threading.Tasks +open Feather.ErrorHandling + +[] +type StorageError = + | ReadFailed of path: string * exn + | WriteFailed of path: string * exn + +let private unwrap: exn -> exn = function + | :? AggregateException as e when not (isNull e.InnerException) -> e.InnerException + | e -> e + +let readText (path: string): AsyncResult = + File.ReadAllTextAsync path + |> AsyncResult.ofTaskCatch (fun e -> StorageError.ReadFailed (path, unwrap e)) + +let writeText (path: string) (content: string): AsyncResult = + File.WriteAllTextAsync(path, content) + |> AsyncResult.ofEmptyTaskCatch (fun e -> StorageError.WriteFailed (path, unwrap e)) + +let copyUpper (source: string) (target: string): AsyncResult = + asyncResult { + let! (content: string) = readText source + do! content.ToUpperInvariant() |> writeText target + } +``` + +## Kleisli composition with operators + +```fs +open Feather.ErrorHandling +open Feather.ErrorHandling.Result.Operators + +[] +type PipelineError = + | Empty + | TooLong of int + +let notEmpty (s: string) = if s = "" then Error PipelineError.Empty else Ok s +let maxLength limit (s: string) = if s.Length > limit then Error (PipelineError.TooLong s.Length) else Ok s + +let toLower (s: string) = s.ToLowerInvariant() + +let validateSlug: string -> Result = + notEmpty + >=> maxLength 20 + >!> toLower + >@> sprintf "%A" + +let slugLength = Ok "Hello" >>= notEmpty String.length // Ok 5 +``` + +## Collections of results + +```fs +open Feather.ErrorHandling + +let parse (raw: string): Result = + match System.Int32.TryParse raw with + | true, value -> Ok value + | _ -> Error $"not a number: {raw}" + +let inputs = [ "1"; "x"; "3"; "y" ] + +inputs |> List.map parse |> Result.sequence // Error "not a number: x" +inputs |> List.map parse |> Validation.ofResults // Error [ "not a number: x"; "not a number: y" ] +inputs |> List.map parse |> Result.list // [ 1; 3 ] +``` + +## Parallel asynchronous work + +`ofParallelAsyncResults` runs every computation and accumulates every `Error`; an exception thrown by any of them becomes the single error from the handler. + +```fs +open Feather.ErrorHandling + +let fetch (id: int): AsyncResult = + asyncResult { + do! AsyncResult.sleep 10 + if id % 2 = 0 then return! Error $"item {id} unavailable" + return $"item {id}" + } + +let all: AsyncResult = + [ 1 .. 5 ] + |> List.map fetch + |> AsyncResult.ofParallelAsyncResults (fun e -> e.Message) +// Error [ "item 2 unavailable"; "item 4 unavailable" ] + +let throttled: AsyncResult = + [ 1; 3; 5 ] + |> List.map fetch + |> AsyncResult.ofMaxParallelAsyncResults 2 (fun e -> e.Message) +// Ok [ "item 1"; "item 3"; "item 5" ] +``` + +## Retry and timeout + +`retryWith log waitMs attempts` runs the computation once and then up to `attempts` more times while it returns `Error`. The computation must be cold (re-executed on each run). + +```fs +open Feather.ErrorHandling + +let mutable calls = 0 + +let flaky: AsyncResult = + asyncResult { + calls <- calls + 1 + if calls < 3 then return! Error $"attempt {calls} failed" + return "done" + } + +let withRetry = + flaky + |> AsyncResult.retryWith (printfn "%s") 100 5 +// prints "Retrying [1/5] in 100ms ..." and "Retrying [2/5] in 100ms ...", then Ok "done" + +let bounded: Async> = + flaky + |> Async.withTimeout 500 (Error "timed out") +``` + +## Test — asserting on workflows with Expecto + +```fs +module Example.WorkflowTest + +open Expecto +open Feather.ErrorHandling + +let private run (workflow: AsyncResult<'T, 'E>) = workflow |> Async.RunSynchronously + +[] +let tests = + testList "Workflows" [ + testCase "should continue with the bound value when the Result is Ok" <| fun _ -> + let actual = + asyncResult { + let! quantity = Ok 3 + return quantity * 2 + } + |> run + + Expect.equal actual (Ok 6) "Should double the bound value" + + testCase "should accumulate every failure when several fields are invalid" <| fun _ -> + let actual: Validation = + validation { + let! a = Error "a" + and! b = Error "b" + return a, b + } + + Expect.equal actual (Error [ "a"; "b" ]) "Should keep failures in binding order" + ] +``` diff --git a/.apm/skills/feather-error-handling/references/preferred-patterns.md b/.apm/skills/feather-error-handling/references/preferred-patterns.md new file mode 100644 index 0000000..7bf2573 --- /dev/null +++ b/.apm/skills/feather-error-handling/references/preferred-patterns.md @@ -0,0 +1,57 @@ +# Preferred Patterns + +## Core Principles + +- Model expected failures as `Result<'T, 'Error>` with a domain error DU, not as exceptions. Exceptions enter the error track only at the boundary, through a `*Catch` lift or `AsyncResult.catch`. +- Pick the shape by failure semantics: `Result` / `result {}` stops at the first error; `Validation` / `validation {}` collects every error; `AsyncResult` / `asyncResult {}` is `Result` inside `Async`. +- `Validation<'T, 'F>` and `AsyncResult<'T, 'E>` are type abbreviations (`Result<'T, 'F list>` and `Async>`), so standard `Result` / `Async` functions apply to them unchanged. +- Prefer `Async` over `Task`. Lift a `Task` into `AsyncResult` as soon as it is created and keep `Task` out of your own signatures. + +## Recommended API Usage + +- Sequential, fail-fast logic: `result {}` with `let!` / `do!` / `return!`; `if cond then return! Error e` works as an early exit. See `examples.md` → Basic. +- Convert at the edges: `Result.ofOption error`, `Result.ofChoice` / `toChoice`, `Result.toOption`, `Option.ofNullable` / `toNullable`, `AsyncResult.ofBool error`. +- Side effects mid-pipeline: `Result.tee` / `teeError`, `AsyncResult.tee` / `teeError`, `Option.tee` / `teeNone`, `Async.tee`. See `examples.md` → Side effects without breaking the chain. +- Error recovery: `Result.bindError` / `AsyncResult.bindError` (error → new `Result`), `Option.bindNone` / `mapNone` / `orDefault` (all take a thunk). +- Collect independent field checks: `validation { let! a = ... and! b = ... return ... }`. Both `Result` and `Validation` values can be bound; a `Result` failure is lifted to a one-item list. See `examples.md` → Validation — accumulating failures. +- Staged validation: bind each `validation {}` stage inside an outer `result {}`. See `examples.md` → Validation stages sequenced with `result {}`. +- Async workflows: `asyncResult {}` binds `AsyncResult`, `Result` (and so `Validation`), and — only when the error type is `exn` — `Async` and `Task`. See `examples.md` → Realistic. +- Task / Async interop: `AsyncResult.ofTaskCatch`, `ofEmptyTaskCatch` (non-generic `Task`), `ofAsyncCatch`, each taking an `exn -> 'Error` mapper. See `examples.md` → Integration. +- Lists: `Result.sequence` (first error), `Validation.sequence` / `Validation.ofResults` (all errors), `Result.sequenceConcat`, `Result.list` / `listCollect` (keep successes only). See `examples.md` → Collections of results. +- Concurrent async work: `AsyncResult.ofParallelAsyncResults`, `ofMaxParallelAsyncResults maxParallel`, `ofSequentialAsyncResults`, and the `*Asyncs` variants for plain `Async<'T>`. See `examples.md` → Parallel asynchronous work. +- Transient failures: `AsyncResult.retry waitMs attempts`, `retryWith log waitMs attempts`, `retryWithExponential log baseWaitMs attempts`; time limits with `Async.withTimeout ms fallback`. See `examples.md` → Retry and timeout. + +## Error Handling + +- One error DU per operation; wrap inner errors in a case of the outer DU and lift with `Result.mapError` / `AsyncResult.mapError` (or `<@>`) at the bind site: `do! authorize user <@> LoginError.Unauthorized`. +- Exception handlers passed to `ofTaskCatch` / `ofEmptyTaskCatch` receive the `AggregateException` from a faulted task; unwrap `InnerException` when the cause matters. `ofAsyncCatch` receives the original exception. +- `Result.orFail` throws (`failwithf "Error %A"`). Use it only where no caller can receive an error: startup, configuration, tests. +- Inside `result {}` and `asyncResult {}`, `try ... with e -> return! Error ...` turns an exception from that block into an error. + +## Composition + +- Kleisli `>=>` composes `'a -> Result<'b, 'e>` functions; `>!>` maps the success, `>@>` maps the error, `>@*>` / `>@@>` tee. `>->` composes error handlers. See `examples.md` → Kleisli composition with operators. +- Operator modules are opt-in: `open Feather.ErrorHandling.Result.Operators`, `AsyncResult.Operators`, or `Option.Operators`. Open only the one the file works in; the same symbols mean different things in each. +- In the operator modules the value is on the left and the function on the right: `r >>= f`, `r f`, `r <@> f`, and `rValue <*> rFunction`. +- `Option.Operators` adds `opt default`, `opt otherOpt`, `opt error` (to `Result`), and `opt |>! action` (`Option.iter`). +- `Async.apply` starts the function computation as a child and runs both in parallel; `Async.map` / `bind` / `retn` / `tee` cover plain `Async`. + +## Integration with Other Libraries + +- The package ships its `.fs` sources under `fable/`, so it can be referenced from Fable projects. +- The CEs (`result`, `asyncResult`, `validation`, `maybe`) are `[]`; `open Feather.ErrorHandling` brings them in. Avoid opening another error-handling library that defines builders with the same names (e.g. FsToolkit.ErrorHandling) in the same file. +- The `Result`, `Option`, `Async` and `AsyncResult` modules are `[]` and extend the FSharp.Core modules of the same name: `Result.map` and `Result.tee` resolve side by side. + +## Naming Conventions + +- `of*` lifts into the type (`ofOption`, `ofTask`, `ofResult`); `to*` converts out (`toOption`, `toChoice`, `toNullable`). +- `*Catch` variants turn exceptions into errors; the plain variant (`ofTask`, `ofAsync`, `ofEmptyTask`) lets them propagate. +- `sequenceM` is monadic (first error, any error type); `sequenceA` is applicative (all errors, error type must be a list). +- `retn` lifts a value (`Async.retn`, `Option.retn`, `AsyncResult.retn` = `ofSuccess`). + +## Testing Recommendations + +- Assert on the full `Result` value (`Expect.equal actual (Error [ ... ]) ...`) so the error case and its payload are both checked. See `examples.md` → Test. +- Run `AsyncResult` in tests with `Async.RunSynchronously`; keep it out of production code paths. +- For `validation {}`, assert the order of accumulated failures — it follows binding order. +- Test retries with a counter captured by a cold `asyncResult {}` and a small `waitMs`, as the library's own retry tests do. diff --git a/.claude/skills/feather-error-handling/SKILL.md b/.claude/skills/feather-error-handling/SKILL.md new file mode 100644 index 0000000..e822e13 --- /dev/null +++ b/.claude/skills/feather-error-handling/SKILL.md @@ -0,0 +1,53 @@ +--- +name: feather-error-handling +description: Use whenever generating or reviewing F# code that handles errors with the Feather.ErrorHandling library — the `result {}`, `asyncResult {}`, `validation {}` (`let! ... and!`) or `maybe {}` computation expressions, the `AsyncResult<'T, 'E>` / `Validation<'T, 'F>` types, lifting Tasks and Asyncs with `AsyncResult.ofTaskCatch` / `ofEmptyTaskCatch` / `ofAsyncCatch` / `ofAsyncOption` / `ofBool`, combining lists via `Result.sequence`, `Validation.ofResults`, `AsyncResult.sequenceM` / `sequenceA` / `ofParallelAsyncResults`, side effects via `tee` / `teeError`, `AsyncResult.retry` / `retryWith`, `Async.withTimeout`, `Result.orFail`, or the `Result.Operators` / `AsyncResult.Operators` / `Option.Operators` symbols (`>>=`, `<@>`, `>=>`, ``). Trigger also on mentions of FeatherTools/error-handling, railway-oriented programming, or accumulating validation errors in F#. +--- + +# Feather-Error-Handling + +Library: [FeatherTools/error-handling](https://github.com/FeatherTools/error-handling) +NuGet: `Feather.ErrorHandling` + +## Purpose + +`Feather.ErrorHandling` extends F#'s `Option`, `Result` and `Async` with the functions and computation expressions needed for railway-oriented error handling: fail-fast `result {}`, applicative `validation {}` that accumulates failures, `asyncResult {}` for asynchronous workflows with typed errors, plus lifting from `Task`/`Async`/`Option`, list sequencing, parallel execution, retries and operator modules. It ships its sources for Fable. + +## When to Use + +- Writing domain logic where failures are values (`Result<'T, 'Error>`) instead of exceptions. +- Collecting every validation failure of a form, command or config record in one pass. +- Composing asynchronous steps that can fail, including .NET `Task` APIs whose exceptions must become typed errors. +- Running a list of fallible async operations in parallel, throttled or in sequence, with all errors collected. +- Retrying transient failures or bounding an async computation with a timeout. + +## When NOT to Use + +- The project already standardises on FsToolkit.ErrorHandling — the CE names (`result`, `asyncResult`, `validation`) collide. +- You need `task {}`-based workflows with typed errors — this library is `Async`-based; `Task` is only an input to lift. +- Exponential backoff, jitter or circuit breaking — `retry*` provide constant or linear waits only. + +## Main Concepts + +- **`result {}`** — fail-fast CE over `Result`; supports `let!`, `do!`, `return!`, `if … then return! Error e`, `for`, `while`, `try/with`, `use`. +- **`Validation<'T, 'F>`** — abbreviation for `Result<'T, 'F list>`; `Validation.apply` / `sequence` / `zip` accumulate failures; `ofResult`, `ofResults`, `toResult` convert. +- **`validation {}`** — applicative CE: `let! … and! …` evaluates every binding and concatenates failures; binds both `Result` and `Validation`; no sequential `let!`. +- **`AsyncResult<'T, 'E>`** — abbreviation for `Async>`; the `AsyncResult` module covers map/bind/tee, lifting (`ofSuccess`, `ofError`, `ofResult`, `ofAsync*`, `ofTask*`, `ofEmptyTask*`, `ofOption`, `ofAsyncOption`, `ofBool`), sequencing and parallel helpers. +- **`asyncResult {}`** — CE over `AsyncResult`; also binds `Result`, and binds `Async` / `Task` with error type `exn`. +- **`maybe {}`** — CE over `Option`. +- **`Result` / `Option` / `Async` modules** — additions to the FSharp.Core modules: `tee`, `teeError`, `bindError`, `ofOption`, `sequence`, `list`, `orFail`; `bindNone`, `mapNone`, `orDefault`, `ofNullable`; `Async.map`, `apply`, `withTimeout`. +- **Operator modules** — `Result.Operators`, `AsyncResult.Operators`, `Option.Operators`; opt-in, value-on-the-left (`r >>= f`, `r <@> f`), Kleisli `>=>`. +- **Retry** — `AsyncResult.retry`, `retryWith`, `retryWithExponential` (linear backoff). + +## Related Libraries + +- **FSharp.Core** — the only dependency; the library extends its `Result`, `Option` and `Async` modules. + +## Keywords for Search + +Feather.ErrorHandling, FeatherTools, error-handling, railway-oriented programming, Result, result CE, Validation, validation CE, and!, applicative, AsyncResult, asyncResult CE, maybe CE, Option, Async, tee, teeError, bindError, ofOption, sequence, sequenceM, sequenceA, Validation.ofResults, Validation.zip, orFail, ofTaskCatch, ofEmptyTaskCatch, ofAsyncCatch, ofAsyncOption, ofBool, ofParallelAsyncResults, ofMaxParallelAsyncResults, ofSequentialAsyncResults, retry, retryWith, retryWithExponential, withTimeout, Operators, >>=, >=>, <@>, , , , , Kleisli, Fable, F# + +## Reference Files + +- For composition principles, recommended API usage, error mapping and testing guidance, read `references/preferred-patterns.md`. +- For CE pitfalls, misleading names, exception leaks and operator gotchas, read `references/anti-patterns.md`. +- For worked, self-contained code examples ordered by increasing complexity, read `references/examples.md`. diff --git a/.claude/skills/feather-error-handling/references/anti-patterns.md b/.claude/skills/feather-error-handling/references/anti-patterns.md new file mode 100644 index 0000000..f389315 --- /dev/null +++ b/.claude/skills/feather-error-handling/references/anti-patterns.md @@ -0,0 +1,53 @@ +# Anti-Patterns + +Format: **mistake → why it is wrong → fix**. + +## Misleading Names and Doc Comments + +- **Expecting `AsyncResult.retryWithExponential` to back off exponentially** → it waits `currentAttempt * baseWaitMs` (linear: 10, 20, 30 ms …); the source comment on `RetryPolicy` says exponential backoff was rejected on purpose → use it for linear backoff and size `baseWaitMs` / `attempts` with that in mind. + +- **Trusting the "Run … in Parallel" doc comment on `AsyncResult.ofSequentialAsyncResults` / `ofSequentialAsyncs`** → both use `Async.Sequential` and run one computation at a time → use `ofParallelAsyncResults` / `ofMaxParallelAsyncResults` when you want concurrency. + +- **Expecting `AsyncResult.sequenceM` to stop executing after the first `Error`** → it returns only the first error, but every computation in the list still runs, in order → if later computations must not run after a failure, bind them one by one in `asyncResult {}`. + +- **Writing `AsyncResult.retry waitMs attempts` and expecting `attempts` total runs** → the computation runs once, then up to `attempts` more times (`retry 100 5` can run 6 times) → pass the number of *retries*, not total attempts. + +## Computation Expression Pitfalls + +- **Sequential `let!` in `validation {}`** → the builder has no `Bind`; two `let!` without `and!` does not compile (FS0708) → join independent checks with `and!`; sequence dependent stages by binding each `validation {}` in an outer `result {}`. See `examples.md` → Validation stages sequenced with `result {}`. + +- **Using `do!`, loops, `try` or `if` without `else` inside `validation {}`** → the builder defines only `Return`, `ReturnFrom`, `BindReturn`, `MergeSources` and `Source`, so these do not compile → move the logic into a `result {}` or a function returning `Result` / `Validation`, then bind it. + +- **Expecting an inner `result {}` bound with `and!` to report several failures** → `result {}` stops at its first error, so it contributes one failure → split independent checks into separate `and!` bindings. + +- **Binding `Async<'T>` or `Task<'T>` inside `asyncResult {}` whose error type is a domain DU** → those overloads fix the error type to `exn`, so the CE fails to type-check → lift first: `let! x = work () |> AsyncResult.ofAsyncCatch MyError.Failed` (or `ofTaskCatch` / `ofEmptyTaskCatch`). + +- **Calling a method or property on a value bound from an `AsyncResult` in `asyncResult {}` without a type annotation** (e.g. `let! content = readText path` then `content.ToUpperInvariant()`) → the `AsyncResult` and `Async<'T>` `Bind` overloads both match, so the type is unknown at the member lookup (FS0072) → annotate the binding: `let! (content: string) = readText path`. Record field access and passing the value to a typed function are unaffected. + +## Exceptions and Interop + +- **Using `AsyncResult.ofTask` / `ofAsync` / `ofEmptyTask` for code that can throw** → these do not catch; the exception escapes when the workflow runs → use `ofTaskCatch` / `ofAsyncCatch` / `ofEmptyTaskCatch` with an `exn -> 'Error` mapper. + +- **Matching the exception type directly in an `ofTaskCatch` handler** → a faulted task reaches the handler as `AggregateException`, so `:? IOException` never matches → unwrap `InnerException` first. See `examples.md` → Integration. + +- **Relying on `AsyncResult.ofAsyncOption` to surface exceptions** → an exception in the `Async<'T option>` is mapped to the same `onMissing` error as `None` → if the two must differ, lift with `ofAsyncCatch` and then `Result.ofOption` / `AsyncResult.ofOption` separately. + +- **Relying on `Async.withTimeout` only for timeouts** → it returns `notInTimeValue` for *any* exception from the computation, not just the timeout → catch domain exceptions inside the computation (e.g. `AsyncResult.catch`) before applying the timeout. + +- **Retrying a computation built from an already-started `Task`** → `retry` re-runs the same `Async`, which re-awaits the same completed task and gets the same result → build the computation lazily, e.g. `asyncResult { return! createTask () |> AsyncResult.ofTaskCatch f }`. + +- **`Result.orFail` in request handlers, consumers or pure logic** → it throws `failwithf "Error %A"` and loses the typed error → propagate the `Result`; reserve `orFail` for startup/configuration code and tests. + +## Operators + +- **Writing `f xR <*> yR` (Haskell style)** → in `Result.Operators` both are flipped: `r f` is `Result.map f r` and `xR <*> fR` is `Result.apply fR xR`; applicative-style construction does not type-check → use `result {}` / `validation {}`, or `Result.apply` / `Validation.apply` directly. + +- **Opening `Result.Operators`, `AsyncResult.Operators` and `Option.Operators` in one file** → they define the same symbols and the last `open` shadows the others; `<@>` is `mapError` for `Result` / `AsyncResult` but `mapNone` for `Option` → open only one operator module per scope, or use the named functions. + +## Silently Dropped Errors + +- **Using `Result.list` / `Result.listCollect` when errors matter** → they keep successes and discard every `Error` → use `Result.sequence` (first error) or `Validation.ofResults` (all errors), or log with `Result.teeError` before filtering. + +- **Using `Result.sequence` to report all invalid inputs** → it is monadic and returns only the first error → use `Validation.sequence` / `Validation.ofResults`. + +- **Ignoring a returned `Result` with `|> ignore`** → the failure disappears → bind it with `do!` / `let!`, or handle it with `Result.iter` / `Result.teeError` explicitly. diff --git a/.claude/skills/feather-error-handling/references/examples.md b/.claude/skills/feather-error-handling/references/examples.md new file mode 100644 index 0000000..516519d --- /dev/null +++ b/.claude/skills/feather-error-handling/references/examples.md @@ -0,0 +1,357 @@ +# Examples + +All example code for this skill lives here. Examples are ordered by increasing complexity and each is self-contained. Neutral placeholder names are used throughout. + +## Basic — `result {}` with a typed error + +```fs +open Feather.ErrorHandling + +[] +type ParseError = + | Empty + | NotANumber of string + | Negative of int + +let parseQuantity (raw: string): Result = + result { + let! raw = if raw = "" then Error ParseError.Empty else Ok raw + + let! quantity = + (match System.Int32.TryParse raw with + | true, value -> Some value + | _ -> None) + |> Result.ofOption (ParseError.NotANumber raw) + + if quantity < 0 then return! Error (ParseError.Negative quantity) + + return quantity + } + +parseQuantity "42" // Ok 42 +parseQuantity "abc" // Error (NotANumber "abc") +parseQuantity "-1" // Error (Negative -1) +``` + +## Side effects without breaking the chain + +`tee` / `teeError` run an effect and pass the value through unchanged. + +```fs +open Feather.ErrorHandling + +let loadSetting (key: string): Result = + if key = "endpoint" then Ok "https://example.test" else Error $"missing setting {key}" + +[ "endpoint"; "timeout" ] +|> List.map ( + loadSetting + >> Result.tee (printfn "loaded: %s") + >> Result.teeError (eprintfn "warning: %s") +) +|> Result.list // [ "https://example.test" ] — errors are dropped, not reported +``` + +## Option helpers and `maybe {}` + +```fs +open Feather.ErrorHandling +open Feather.ErrorHandling.Option.Operators + +let tryFindPort (name: string): int option = + if name = "api" then Some 8080 else None + +let address = + maybe { + let! port = tryFindPort "api" + return $"localhost:{port}" + } + +let port = tryFindPort "worker" 80 // 80 +let fallback = tryFindPort "worker" tryFindPort "api" // Some 8080 +let required = tryFindPort "worker" "port not set" // Error "port not set" + +let lazyDefault = tryFindPort "worker" |> Option.orDefault (fun () -> 80) +let nullable = System.Nullable 3 |> Option.ofNullable // Some 3 +``` + +## Validation — accumulating failures + +`let! ... and! ...` evaluates every binding and concatenates all failures. A `Result` binding is lifted to a one-element failure list; an inner `result {}` contributes at most one failure. + +```fs +open Feather.ErrorHandling + +type Account = { Name: string; Age: int; Email: string } + +[] +type AccountError = + | NameEmpty + | AgeInvalid of string + | AgeTooLow of int + | EmailInvalid of string + +let validateName name = + if name = "" then Error AccountError.NameEmpty else Ok name + +let validateEmail (email: string) = + if email.Contains "@" then Ok email else Error (AccountError.EmailInvalid email) + +let validateAge (raw: string) = + result { + let! age = + match System.Int32.TryParse raw with + | true, value -> Ok value + | _ -> Error (AccountError.AgeInvalid raw) + + if age < 18 then return! Error (AccountError.AgeTooLow age) + + return age + } + +let createAccount name age email: Validation = + validation { + let! name = validateName name + and! age = validateAge age + and! email = validateEmail email + + return { Name = name; Age = age; Email = email } + } + +createAccount "alice" "30" "alice@example.test" // Ok { ... } +createAccount "" "16" "nope" +// Error [ NameEmpty; AgeTooLow 16; EmailInvalid "nope" ] +``` + +## Validation stages sequenced with `result {}` + +`validation {}` has no sequential `let!`. A `Validation` is a `Result<_, _ list>`, so bind whole stages in an outer `result {}`: the second stage runs only when the first succeeded. + +```fs +open Feather.ErrorHandling + +[] +type OrderError = + | SkuEmpty + | QuantityNotPositive of int + | OutOfStock of string + +let checkStock (sku: string) (quantity: int) = + if sku = "sold-out" then Error (OrderError.OutOfStock sku) else Ok (sku, quantity) + +let placeOrder (sku: string) (quantity: int): Validation = + result { + let! sku, quantity = + validation { + let! sku = if sku = "" then Error OrderError.SkuEmpty else Ok sku + and! quantity = if quantity <= 0 then Error (OrderError.QuantityNotPositive quantity) else Ok quantity + + return sku, quantity + } + + return! checkStock sku quantity |> Validation.ofResult + } + +placeOrder "" 0 // Error [ SkuEmpty; QuantityNotPositive 0 ] — stock never checked +placeOrder "sold-out" 1 // Error [ OutOfStock "sold-out" ] +``` + +## Realistic — `asyncResult {}` workflow + +Mirrors the library's login test: lift `Async<'T option>`, `bool`, `AsyncResult` and `Result` into one workflow, mapping each inner error into the workflow's error type. + +```fs +open Feather.ErrorHandling +open Feather.ErrorHandling.AsyncResult.Operators + +type User = { Username: string; Password: string } +type Token = Token of string + +type AuthError = AuthError of string +type TokenError = TokenError of string + +[] +type LoginError = + | InvalidUser + | InvalidPassword + | Unauthorized of AuthError + | TokenFailed of TokenError + +let login + (tryGetUser: string -> Async) + (authorize: User -> AsyncResult) + (createToken: User -> Result) + (username: string, password: string): AsyncResult = + + asyncResult { + let! user = username |> tryGetUser |> AsyncResult.ofAsyncOption LoginError.InvalidUser + do! user.Password = password |> AsyncResult.ofBool LoginError.InvalidPassword + do! user |> authorize <@> LoginError.Unauthorized + + return! user |> createToken |> Result.mapError LoginError.TokenFailed + } +``` + +## Integration — Task-based .NET APIs + +Lift every `Task` at the boundary with a `*Catch` variant so exceptions become a typed error. A faulted `Task` arrives in the handler as an `AggregateException`; unwrap it when the inner exception matters. + +```fs +open System +open System.IO +open System.Threading.Tasks +open Feather.ErrorHandling + +[] +type StorageError = + | ReadFailed of path: string * exn + | WriteFailed of path: string * exn + +let private unwrap: exn -> exn = function + | :? AggregateException as e when not (isNull e.InnerException) -> e.InnerException + | e -> e + +let readText (path: string): AsyncResult = + File.ReadAllTextAsync path + |> AsyncResult.ofTaskCatch (fun e -> StorageError.ReadFailed (path, unwrap e)) + +let writeText (path: string) (content: string): AsyncResult = + File.WriteAllTextAsync(path, content) + |> AsyncResult.ofEmptyTaskCatch (fun e -> StorageError.WriteFailed (path, unwrap e)) + +let copyUpper (source: string) (target: string): AsyncResult = + asyncResult { + let! (content: string) = readText source + do! content.ToUpperInvariant() |> writeText target + } +``` + +## Kleisli composition with operators + +```fs +open Feather.ErrorHandling +open Feather.ErrorHandling.Result.Operators + +[] +type PipelineError = + | Empty + | TooLong of int + +let notEmpty (s: string) = if s = "" then Error PipelineError.Empty else Ok s +let maxLength limit (s: string) = if s.Length > limit then Error (PipelineError.TooLong s.Length) else Ok s + +let toLower (s: string) = s.ToLowerInvariant() + +let validateSlug: string -> Result = + notEmpty + >=> maxLength 20 + >!> toLower + >@> sprintf "%A" + +let slugLength = Ok "Hello" >>= notEmpty String.length // Ok 5 +``` + +## Collections of results + +```fs +open Feather.ErrorHandling + +let parse (raw: string): Result = + match System.Int32.TryParse raw with + | true, value -> Ok value + | _ -> Error $"not a number: {raw}" + +let inputs = [ "1"; "x"; "3"; "y" ] + +inputs |> List.map parse |> Result.sequence // Error "not a number: x" +inputs |> List.map parse |> Validation.ofResults // Error [ "not a number: x"; "not a number: y" ] +inputs |> List.map parse |> Result.list // [ 1; 3 ] +``` + +## Parallel asynchronous work + +`ofParallelAsyncResults` runs every computation and accumulates every `Error`; an exception thrown by any of them becomes the single error from the handler. + +```fs +open Feather.ErrorHandling + +let fetch (id: int): AsyncResult = + asyncResult { + do! AsyncResult.sleep 10 + if id % 2 = 0 then return! Error $"item {id} unavailable" + return $"item {id}" + } + +let all: AsyncResult = + [ 1 .. 5 ] + |> List.map fetch + |> AsyncResult.ofParallelAsyncResults (fun e -> e.Message) +// Error [ "item 2 unavailable"; "item 4 unavailable" ] + +let throttled: AsyncResult = + [ 1; 3; 5 ] + |> List.map fetch + |> AsyncResult.ofMaxParallelAsyncResults 2 (fun e -> e.Message) +// Ok [ "item 1"; "item 3"; "item 5" ] +``` + +## Retry and timeout + +`retryWith log waitMs attempts` runs the computation once and then up to `attempts` more times while it returns `Error`. The computation must be cold (re-executed on each run). + +```fs +open Feather.ErrorHandling + +let mutable calls = 0 + +let flaky: AsyncResult = + asyncResult { + calls <- calls + 1 + if calls < 3 then return! Error $"attempt {calls} failed" + return "done" + } + +let withRetry = + flaky + |> AsyncResult.retryWith (printfn "%s") 100 5 +// prints "Retrying [1/5] in 100ms ..." and "Retrying [2/5] in 100ms ...", then Ok "done" + +let bounded: Async> = + flaky + |> Async.withTimeout 500 (Error "timed out") +``` + +## Test — asserting on workflows with Expecto + +```fs +module Example.WorkflowTest + +open Expecto +open Feather.ErrorHandling + +let private run (workflow: AsyncResult<'T, 'E>) = workflow |> Async.RunSynchronously + +[] +let tests = + testList "Workflows" [ + testCase "should continue with the bound value when the Result is Ok" <| fun _ -> + let actual = + asyncResult { + let! quantity = Ok 3 + return quantity * 2 + } + |> run + + Expect.equal actual (Ok 6) "Should double the bound value" + + testCase "should accumulate every failure when several fields are invalid" <| fun _ -> + let actual: Validation = + validation { + let! a = Error "a" + and! b = Error "b" + return a, b + } + + Expect.equal actual (Error [ "a"; "b" ]) "Should keep failures in binding order" + ] +``` diff --git a/.claude/skills/feather-error-handling/references/preferred-patterns.md b/.claude/skills/feather-error-handling/references/preferred-patterns.md new file mode 100644 index 0000000..7bf2573 --- /dev/null +++ b/.claude/skills/feather-error-handling/references/preferred-patterns.md @@ -0,0 +1,57 @@ +# Preferred Patterns + +## Core Principles + +- Model expected failures as `Result<'T, 'Error>` with a domain error DU, not as exceptions. Exceptions enter the error track only at the boundary, through a `*Catch` lift or `AsyncResult.catch`. +- Pick the shape by failure semantics: `Result` / `result {}` stops at the first error; `Validation` / `validation {}` collects every error; `AsyncResult` / `asyncResult {}` is `Result` inside `Async`. +- `Validation<'T, 'F>` and `AsyncResult<'T, 'E>` are type abbreviations (`Result<'T, 'F list>` and `Async>`), so standard `Result` / `Async` functions apply to them unchanged. +- Prefer `Async` over `Task`. Lift a `Task` into `AsyncResult` as soon as it is created and keep `Task` out of your own signatures. + +## Recommended API Usage + +- Sequential, fail-fast logic: `result {}` with `let!` / `do!` / `return!`; `if cond then return! Error e` works as an early exit. See `examples.md` → Basic. +- Convert at the edges: `Result.ofOption error`, `Result.ofChoice` / `toChoice`, `Result.toOption`, `Option.ofNullable` / `toNullable`, `AsyncResult.ofBool error`. +- Side effects mid-pipeline: `Result.tee` / `teeError`, `AsyncResult.tee` / `teeError`, `Option.tee` / `teeNone`, `Async.tee`. See `examples.md` → Side effects without breaking the chain. +- Error recovery: `Result.bindError` / `AsyncResult.bindError` (error → new `Result`), `Option.bindNone` / `mapNone` / `orDefault` (all take a thunk). +- Collect independent field checks: `validation { let! a = ... and! b = ... return ... }`. Both `Result` and `Validation` values can be bound; a `Result` failure is lifted to a one-item list. See `examples.md` → Validation — accumulating failures. +- Staged validation: bind each `validation {}` stage inside an outer `result {}`. See `examples.md` → Validation stages sequenced with `result {}`. +- Async workflows: `asyncResult {}` binds `AsyncResult`, `Result` (and so `Validation`), and — only when the error type is `exn` — `Async` and `Task`. See `examples.md` → Realistic. +- Task / Async interop: `AsyncResult.ofTaskCatch`, `ofEmptyTaskCatch` (non-generic `Task`), `ofAsyncCatch`, each taking an `exn -> 'Error` mapper. See `examples.md` → Integration. +- Lists: `Result.sequence` (first error), `Validation.sequence` / `Validation.ofResults` (all errors), `Result.sequenceConcat`, `Result.list` / `listCollect` (keep successes only). See `examples.md` → Collections of results. +- Concurrent async work: `AsyncResult.ofParallelAsyncResults`, `ofMaxParallelAsyncResults maxParallel`, `ofSequentialAsyncResults`, and the `*Asyncs` variants for plain `Async<'T>`. See `examples.md` → Parallel asynchronous work. +- Transient failures: `AsyncResult.retry waitMs attempts`, `retryWith log waitMs attempts`, `retryWithExponential log baseWaitMs attempts`; time limits with `Async.withTimeout ms fallback`. See `examples.md` → Retry and timeout. + +## Error Handling + +- One error DU per operation; wrap inner errors in a case of the outer DU and lift with `Result.mapError` / `AsyncResult.mapError` (or `<@>`) at the bind site: `do! authorize user <@> LoginError.Unauthorized`. +- Exception handlers passed to `ofTaskCatch` / `ofEmptyTaskCatch` receive the `AggregateException` from a faulted task; unwrap `InnerException` when the cause matters. `ofAsyncCatch` receives the original exception. +- `Result.orFail` throws (`failwithf "Error %A"`). Use it only where no caller can receive an error: startup, configuration, tests. +- Inside `result {}` and `asyncResult {}`, `try ... with e -> return! Error ...` turns an exception from that block into an error. + +## Composition + +- Kleisli `>=>` composes `'a -> Result<'b, 'e>` functions; `>!>` maps the success, `>@>` maps the error, `>@*>` / `>@@>` tee. `>->` composes error handlers. See `examples.md` → Kleisli composition with operators. +- Operator modules are opt-in: `open Feather.ErrorHandling.Result.Operators`, `AsyncResult.Operators`, or `Option.Operators`. Open only the one the file works in; the same symbols mean different things in each. +- In the operator modules the value is on the left and the function on the right: `r >>= f`, `r f`, `r <@> f`, and `rValue <*> rFunction`. +- `Option.Operators` adds `opt default`, `opt otherOpt`, `opt error` (to `Result`), and `opt |>! action` (`Option.iter`). +- `Async.apply` starts the function computation as a child and runs both in parallel; `Async.map` / `bind` / `retn` / `tee` cover plain `Async`. + +## Integration with Other Libraries + +- The package ships its `.fs` sources under `fable/`, so it can be referenced from Fable projects. +- The CEs (`result`, `asyncResult`, `validation`, `maybe`) are `[]`; `open Feather.ErrorHandling` brings them in. Avoid opening another error-handling library that defines builders with the same names (e.g. FsToolkit.ErrorHandling) in the same file. +- The `Result`, `Option`, `Async` and `AsyncResult` modules are `[]` and extend the FSharp.Core modules of the same name: `Result.map` and `Result.tee` resolve side by side. + +## Naming Conventions + +- `of*` lifts into the type (`ofOption`, `ofTask`, `ofResult`); `to*` converts out (`toOption`, `toChoice`, `toNullable`). +- `*Catch` variants turn exceptions into errors; the plain variant (`ofTask`, `ofAsync`, `ofEmptyTask`) lets them propagate. +- `sequenceM` is monadic (first error, any error type); `sequenceA` is applicative (all errors, error type must be a list). +- `retn` lifts a value (`Async.retn`, `Option.retn`, `AsyncResult.retn` = `ofSuccess`). + +## Testing Recommendations + +- Assert on the full `Result` value (`Expect.equal actual (Error [ ... ]) ...`) so the error case and its payload are both checked. See `examples.md` → Test. +- Run `AsyncResult` in tests with `Async.RunSynchronously`; keep it out of production code paths. +- For `validation {}`, assert the order of accumulated failures — it follows binding order. +- Test retries with a counter captured by a cold `asyncResult {}` and a small `waitMs`, as the library's own retry tests do. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..baef669 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Copilot APM outputs +**/.agents/skills/** linguist-generated +**/.github/agents/** linguist-generated +**/.github/hooks/** linguist-generated +**/.github/instructions/** linguist-generated + +# Claude APM outputs +**/.claude/agents/** linguist-generated +**/.claude/apm-hooks.json linguist-generated +**/.claude/hooks/** linguist-generated +**/.claude/rules/** linguist-generated +**/.claude/settings.json linguist-generated +**/.claude/skills/** linguist-generated diff --git a/.gitignore b/.gitignore index 43851c3..1b34cf7 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ paket-files/ # Released release/*.nupkg + +# APM dependencies +apm_modules/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bf2ef10 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# AGENTS.md — Feather.ErrorHandling + +This repo ships Agent Skill for the `Feather.ErrorHandling` library. Compatible agents discover it automatically; see `.agents/skills/feather-error-handling/SKILL.md`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aff1b76 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ + +@AGENTS.md diff --git a/apm.lock.yaml b/apm.lock.yaml new file mode 100644 index 0000000..7d47ced --- /dev/null +++ b/apm.lock.yaml @@ -0,0 +1,113 @@ +lockfile_version: '1' +dependencies: [] +deployments: +- kind: project-relative + target: claude + value: .claude/skills/feather-error-handling + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: claude + value: .claude/skills/feather-error-handling/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:db0b8335114cc823eedc7c81b65b1864183a9bb553b576dbb730c6d299e8a6e8 +- kind: project-relative + target: claude + value: .claude/skills/feather-error-handling/references/anti-patterns.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:fd30b8d7abbf265fecd361919ccdcf323198a7c2f914e6b382a7999b47f3bd06 +- kind: project-relative + target: claude + value: .claude/skills/feather-error-handling/references/examples.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:6febe30cfacd2feb14445457b563c274ef98a2619e3256bbf09f350c1833a7fe +- kind: project-relative + target: claude + value: .claude/skills/feather-error-handling/references/preferred-patterns.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:ff773b24794d451a950a58ca60843ff48da5bea478128de563767144664bde3d +- kind: project-relative + target: legacy + value: .agents/skills/feather-error-handling + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: legacy + value: .agents/skills/feather-error-handling/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:db0b8335114cc823eedc7c81b65b1864183a9bb553b576dbb730c6d299e8a6e8 +- kind: project-relative + target: legacy + value: .agents/skills/feather-error-handling/references/anti-patterns.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:fd30b8d7abbf265fecd361919ccdcf323198a7c2f914e6b382a7999b47f3bd06 +- kind: project-relative + target: legacy + value: .agents/skills/feather-error-handling/references/examples.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:6febe30cfacd2feb14445457b563c274ef98a2619e3256bbf09f350c1833a7fe +- kind: project-relative + target: legacy + value: .agents/skills/feather-error-handling/references/preferred-patterns.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:ff773b24794d451a950a58ca60843ff48da5bea478128de563767144664bde3d +local_deployed_files: +- .agents/skills/feather-error-handling +- .agents/skills/feather-error-handling/SKILL.md +- .agents/skills/feather-error-handling/references/anti-patterns.md +- .agents/skills/feather-error-handling/references/examples.md +- .agents/skills/feather-error-handling/references/preferred-patterns.md +- .claude/skills/feather-error-handling +- .claude/skills/feather-error-handling/SKILL.md +- .claude/skills/feather-error-handling/references/anti-patterns.md +- .claude/skills/feather-error-handling/references/examples.md +- .claude/skills/feather-error-handling/references/preferred-patterns.md +local_deployed_file_hashes: + .agents/skills/feather-error-handling/SKILL.md: sha256:db0b8335114cc823eedc7c81b65b1864183a9bb553b576dbb730c6d299e8a6e8 + .agents/skills/feather-error-handling/references/anti-patterns.md: sha256:fd30b8d7abbf265fecd361919ccdcf323198a7c2f914e6b382a7999b47f3bd06 + .agents/skills/feather-error-handling/references/examples.md: sha256:6febe30cfacd2feb14445457b563c274ef98a2619e3256bbf09f350c1833a7fe + .agents/skills/feather-error-handling/references/preferred-patterns.md: sha256:ff773b24794d451a950a58ca60843ff48da5bea478128de563767144664bde3d + .claude/skills/feather-error-handling/SKILL.md: sha256:db0b8335114cc823eedc7c81b65b1864183a9bb553b576dbb730c6d299e8a6e8 + .claude/skills/feather-error-handling/references/anti-patterns.md: sha256:fd30b8d7abbf265fecd361919ccdcf323198a7c2f914e6b382a7999b47f3bd06 + .claude/skills/feather-error-handling/references/examples.md: sha256:6febe30cfacd2feb14445457b563c274ef98a2619e3256bbf09f350c1833a7fe + .claude/skills/feather-error-handling/references/preferred-patterns.md: sha256:ff773b24794d451a950a58ca60843ff48da5bea478128de563767144664bde3d diff --git a/apm.yml b/apm.yml new file mode 100644 index 0000000..3f19cbc --- /dev/null +++ b/apm.yml @@ -0,0 +1,15 @@ +name: error-handling +version: 1.0.0 +description: APM project for error-handling +author: FeatherTools +# Which agent platforms to deploy to. +# Resolution order: --target flag > this field > auto-detect from filesystem. +# Accepted values: agent-skills, antigravity, claude, codex, copilot, cursor, gemini, grok-build, hermes, kiro, opencode, vscode, windsurf +targets: +- claude +- copilot +dependencies: + apm: [] + mcp: [] +includes: auto +scripts: {}