1
0
mirror of https://github.com/IBM/fp-go.git synced 2026-01-13 00:44:11 +02:00

Compare commits

...

6 Commits

Author SHA1 Message Date
Carsten Leue
e010f13dce fix: initial version of circuit breaker (#151)
* fix: add circuitbreaker and doc

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: refactor and more low level tests

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: document thread safety

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: add stateio

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: documentation of StateIO monad

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: initial version of circuitbreaker

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

---------

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-12 18:19:39 +01:00
Carsten Leue
86a260a204 Introduce IORef (#150)
* fix: add ioref and tests

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: better tests

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

---------

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-04 16:45:40 +01:00
lif
6a6b982779 feat: Add OrElse to ioeither for error recovery (#148)
* feat: add OrElse to ioeither for error recovery

Add OrElse function to both v1 and v2 ioeither packages for error recovery.
This allows recovering from a Left value by applying a function to the error
and returning a new IOEither, consistent with the Either package's API.

- Add OrElse to ioeither/generic/ioeither.go
- Add OrElse wrapper to ioeither/ioeither.go
- Add OrElse to v2/ioeither/generic/ioeither.go
- Add OrElse to v2/ioeither/ioeither.go
- Add comprehensive tests for both v1 and v2

Closes #146

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: majiayu000 <1835304752@qq.com>

* chore(v2): drop ioeither OrElse addition

---------

Signed-off-by: majiayu000 <1835304752@qq.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:41:13 +01:00
Carsten Leue
9d31752887 Rewrite the Retry logic based on Trampoline (#149)
* fix: implement retry via tail rec

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: base retry on Trampoline

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: refactor retry

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

---------

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-02 15:43:51 +01:00
Carsten Leue
14b52568b5 Add OrElse consistently and improve docs (#147)
* fix: OrElse

Signed-off-by: Carsten Leue <carsten.leue@de.ibm.com>

* fix: improve tests

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: FilterOrElse

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: tests and doc

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: add sample

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: add tests for CopyFile

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: signature of Close

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

---------

Signed-off-by: Carsten Leue <carsten.leue@de.ibm.com>
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-31 15:59:10 +01:00
Dr. Carsten Leue
49227551b6 fix: more iter methods
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-22 15:03:47 +01:00
248 changed files with 23681 additions and 796 deletions

View File

@@ -369,6 +369,11 @@ func ToIOOption[GA ~func() O.Option[A], GEA ~func() ET.Either[E, A], E, A any](i
)
}
// OrElse returns the original IOEither if it is a Right, otherwise it applies the given function to the error and returns the result.
func OrElse[GA ~func() ET.Either[E, A], E, A any](onLeft func(E) GA) func(GA) GA {
return eithert.OrElse(IO.MonadChain[GA, GA, ET.Either[E, A], ET.Either[E, A]], IO.Of[GA, ET.Either[E, A]], onLeft)
}
func FromIOOption[GEA ~func() ET.Either[E, A], GA ~func() O.Option[A], E, A any](onNone func() E) func(ioo GA) GEA {
return IO.Map[GA, GEA](ET.FromOption[A](onNone))
}

View File

@@ -266,6 +266,11 @@ func Alt[E, A any](second L.Lazy[IOEither[E, A]]) func(IOEither[E, A]) IOEither[
return G.Alt(second)
}
// OrElse returns the original IOEither if it is a Right, otherwise it applies the given function to the error and returns the result.
func OrElse[E, A any](onLeft func(E) IOEither[E, A]) func(IOEither[E, A]) IOEither[E, A] {
return G.OrElse[IOEither[E, A]](onLeft)
}
func MonadFlap[E, B, A any](fab IOEither[E, func(A) B], a A) IOEither[E, B] {
return G.MonadFlap[IOEither[E, func(A) B], IOEither[E, B]](fab, a)
}

View File

@@ -134,3 +134,44 @@ func TestApSecond(t *testing.T) {
assert.Equal(t, E.Of[error]("b"), x())
}
func TestOrElse(t *testing.T) {
// Test that OrElse recovers from a Left
recover := OrElse(func(err string) IOEither[string, int] {
return Right[string](42)
})
// When input is Left, should recover
leftResult := F.Pipe1(
Left[int]("error"),
recover,
)
assert.Equal(t, E.Right[string](42), leftResult())
// When input is Right, should pass through unchanged
rightResult := F.Pipe1(
Right[string](100),
recover,
)
assert.Equal(t, E.Right[string](100), rightResult())
// Test that OrElse can also return a Left (propagate different error)
recoverOrFail := OrElse(func(err string) IOEither[string, int] {
if err == "recoverable" {
return Right[string](0)
}
return Left[int]("unrecoverable: " + err)
})
recoverable := F.Pipe1(
Left[int]("recoverable"),
recoverOrFail,
)
assert.Equal(t, E.Right[string](0), recoverable())
unrecoverable := F.Pipe1(
Left[int]("fatal"),
recoverOrFail,
)
assert.Equal(t, E.Left[int]("unrecoverable: fatal"), unrecoverable())
}

View File

@@ -55,7 +55,7 @@ import (
// Create a pipeline of transformations
pipeline := F.Flow3(
A.Filter(func(x int) bool { return x > 0 }), // Keep positive numbers
A.Filter(N.MoreThan(0)), // Keep positive numbers
A.Map(N.Mul(2)), // Double each number
A.Reduce(func(acc, x int) int { return acc + x }, 0), // Sum them up
)

View File

@@ -16,22 +16,11 @@
package array
import (
"slices"
E "github.com/IBM/fp-go/v2/eq"
)
func equals[T any](left, right []T, eq func(T, T) bool) bool {
if len(left) != len(right) {
return false
}
for i, v1 := range left {
v2 := right[i]
if !eq(v1, v2) {
return false
}
}
return true
}
// Eq creates an equality checker for arrays given an equality checker for elements.
// Two arrays are considered equal if they have the same length and all corresponding
// elements are equal according to the provided Eq instance.
@@ -46,6 +35,11 @@ func equals[T any](left, right []T, eq func(T, T) bool) bool {
func Eq[T any](e E.Eq[T]) E.Eq[[]T] {
eq := e.Equals
return E.FromEquals(func(left, right []T) bool {
return equals(left, right, eq)
return slices.EqualFunc(left, right, eq)
})
}
//go:inline
func StrictEquals[T comparable]() E.Eq[[]T] {
return E.FromEquals(slices.Equal[[]T])
}

View File

@@ -3,13 +3,18 @@ package nonempty
import "github.com/IBM/fp-go/v2/option"
type (
// NonEmptyArray represents an array with at least one element
// NonEmptyArray represents an array that is guaranteed to have at least one element.
// This provides compile-time safety for operations that require non-empty collections.
NonEmptyArray[A any] []A
// Kleisli represents a Kleisli arrow for the NonEmptyArray monad.
// It's a function from A to NonEmptyArray[B], used for composing operations that produce non-empty arrays.
Kleisli[A, B any] = func(A) NonEmptyArray[B]
// Operator represents a function that transforms one NonEmptyArray into another.
// It takes a NonEmptyArray[A] and produces a NonEmptyArray[B].
Operator[A, B any] = Kleisli[NonEmptyArray[A], B]
// Option represents an optional value that may or may not be present.
Option[A any] = option.Option[A]
)

View File

@@ -3,7 +3,14 @@ package array
import "github.com/IBM/fp-go/v2/option"
type (
Kleisli[A, B any] = func(A) []B
// Kleisli represents a Kleisli arrow for arrays.
// It's a function from A to []B, used for composing operations that produce arrays.
Kleisli[A, B any] = func(A) []B
// Operator represents a function that transforms one array into another.
// It takes a []A and produces a []B.
Operator[A, B any] = Kleisli[[]A, B]
Option[A any] = option.Option[A]
// Option represents an optional value that may or may not be present.
Option[A any] = option.Option[A]
)

View File

@@ -57,7 +57,7 @@
// assert.ArrayNotEmpty(arr)(t)
//
// // Partial application - create reusable assertions
// isPositive := assert.That(func(n int) bool { return n > 0 })
// isPositive := assert.That(N.MoreThan(0))
// // Later, apply to different values:
// isPositive(42)(t) // Passes
// isPositive(-5)(t) // Fails
@@ -416,7 +416,7 @@ func NotContainsKey[T any, K comparable](expected K) Kleisli[map[K]T] {
//
// func TestThat(t *testing.T) {
// // Test if a number is positive
// isPositive := func(n int) bool { return n > 0 }
// isPositive := N.MoreThan(0)
// assert.That(isPositive)(42)(t) // Passes
// assert.That(isPositive)(-5)(t) // Fails
//

View File

@@ -21,6 +21,7 @@ import (
"testing"
"github.com/IBM/fp-go/v2/assert"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/result"
)
@@ -111,7 +112,7 @@ func Example_predicateAssertions() {
var t *testing.T // placeholder for example
// Test if a number is positive
isPositive := func(n int) bool { return n > 0 }
isPositive := N.MoreThan(0)
assert.That(isPositive)(42)(t)
// Test if a string is uppercase

View File

@@ -12,11 +12,24 @@ import (
)
type (
Result[T any] = result.Result[T]
Reader = reader.Reader[*testing.T, bool]
Kleisli[T any] = reader.Reader[T, Reader]
Predicate[T any] = predicate.Predicate[T]
Lens[S, T any] = lens.Lens[S, T]
// Result represents a computation that may fail with an error.
Result[T any] = result.Result[T]
// Reader represents a test assertion that depends on a testing.T context and returns a boolean.
Reader = reader.Reader[*testing.T, bool]
// Kleisli represents a function that produces a test assertion Reader from a value of type T.
Kleisli[T any] = reader.Reader[T, Reader]
// Predicate represents a function that tests a value of type T and returns a boolean.
Predicate[T any] = predicate.Predicate[T]
// Lens is a functional reference to a subpart of a data structure.
Lens[S, T any] = lens.Lens[S, T]
// Optional is an optic that focuses on a value that may or may not be present.
Optional[S, T any] = optional.Optional[S, T]
Prism[S, T any] = prism.Prism[S, T]
// Prism is an optic that focuses on a case of a sum type.
Prism[S, T any] = prism.Prism[S, T]
)

View File

@@ -18,5 +18,7 @@ package boolean
import "github.com/IBM/fp-go/v2/monoid"
type (
// Monoid represents a monoid structure for boolean values.
// A monoid provides an associative binary operation and an identity element.
Monoid = monoid.Monoid[bool]
)

View File

@@ -7,9 +7,14 @@ import (
)
type (
// Result represents a computation that may fail with an error.
// It's an alias for Either[error, T].
Result[T any] = result.Result[T]
// Prism is an optic that focuses on a case of a sum type.
// It provides a way to extract and construct values of a specific variant.
Prism[S, A any] = prism.Prism[S, A]
// Option represents an optional value that may or may not be present.
Option[T any] = option.Option[T]
)

View File

@@ -0,0 +1,623 @@
package circuitbreaker
import (
"time"
"github.com/IBM/fp-go/v2/either"
"github.com/IBM/fp-go/v2/function"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/identity"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioref"
"github.com/IBM/fp-go/v2/lazy"
"github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/retry"
)
var (
canaryRequestLens = lens.MakeLensWithName(
func(os openState) bool { return os.canaryRequest },
func(os openState, flag bool) openState {
os.canaryRequest = flag
return os
},
"openState.CanaryRequest",
)
retryStatusLens = lens.MakeLensWithName(
func(os openState) retry.RetryStatus { return os.retryStatus },
func(os openState, status retry.RetryStatus) openState {
os.retryStatus = status
return os
},
"openState.RetryStatus",
)
resetAtLens = lens.MakeLensWithName(
func(os openState) time.Time { return os.resetAt },
func(os openState, tm time.Time) openState {
os.resetAt = tm
return os
},
"openState.ResetAt",
)
openedAtLens = lens.MakeLensWithName(
func(os openState) time.Time { return os.openedAt },
func(os openState, tm time.Time) openState {
os.openedAt = tm
return os
},
"openState.OpenedAt",
)
createClosedCircuit = either.Right[openState, ClosedState]
createOpenCircuit = either.Left[ClosedState, openState]
// MakeClosedIORef creates an IORef containing a closed circuit breaker state.
// It wraps the provided ClosedState in a Right (closed) BreakerState and creates
// a mutable reference to it.
//
// Parameters:
// - closedState: The initial closed state configuration
//
// Returns:
// - An IO operation that creates an IORef[BreakerState] initialized to closed state
//
// Thread Safety: The returned IORef[BreakerState] is thread-safe. It uses atomic
// operations for all read/write/modify operations. The BreakerState itself is immutable.
MakeClosedIORef = F.Flow2(
createClosedCircuit,
ioref.MakeIORef,
)
// IsOpen checks if a BreakerState is in the open state.
// Returns true if the circuit breaker is open (blocking requests), false otherwise.
IsOpen = either.IsLeft[openState, ClosedState]
// IsClosed checks if a BreakerState is in the closed state.
// Returns true if the circuit breaker is closed (allowing requests), false otherwise.
IsClosed = either.IsRight[openState, ClosedState]
// modifyV creates a Reader that sequences an IORef modification operation.
// It takes an IORef[BreakerState] and returns a Reader that, when given an endomorphism
// (a function from BreakerState to BreakerState), produces an IO operation that modifies
// the IORef and returns the new state.
//
// This is used internally to create state modification operations that can be composed
// with other Reader-based operations in the circuit breaker logic.
//
// Thread Safety: The IORef modification is atomic. Multiple concurrent calls will be
// serialized by the IORef's atomic operations.
//
// Type signature: Reader[IORef[BreakerState], IO[Endomorphism[BreakerState]]]
modifyV = reader.Sequence(ioref.Modify[BreakerState])
initialRetry = retry.DefaultRetryStatus
// testCircuit sets the canaryRequest flag to true in an openState.
// This is used to mark that the circuit breaker is in half-open state,
// allowing a single test request (canary) to check if the service has recovered.
//
// When canaryRequest is true:
// - One request is allowed through to test the service
// - If the canary succeeds, the circuit closes
// - If the canary fails, the circuit remains open with an extended reset time
//
// Thread Safety: This is a pure function that returns a new openState; it does not
// modify its input. Safe for concurrent use.
//
// Type signature: Endomorphism[openState]
testCircuit = canaryRequestLens.Set(true)
)
// makeOpenCircuitFromPolicy creates a function that constructs an openState from a retry policy.
// This is a curried function that takes a retry policy and returns a function that takes a retry status
// and current time to produce an openState with calculated reset time.
//
// The function applies the retry policy to determine the next retry delay and calculates
// the resetAt time by adding the delay to the current time. If no previous delay exists
// (first failure), the resetAt is set to the current time.
//
// Parameters:
// - policy: The retry policy that determines backoff strategy (e.g., exponential backoff)
//
// Returns:
// - A curried function that takes:
// 1. rs (retry.RetryStatus): The current retry status containing retry count and previous delay
// 2. ct (time.Time): The current time when the circuit is opening
// And returns an openState with:
// - openedAt: Set to the current time (ct)
// - resetAt: Current time plus the delay from the retry policy
// - retryStatus: The updated retry status from applying the policy
// - canaryRequest: false (will be set to true when reset time is reached)
//
// Thread Safety: This is a pure function that creates new openState instances.
// Safe for concurrent use.
//
// Example:
//
// policy := retry.ExponentialBackoff(1*time.Second, 2.0, 10)
// makeOpen := makeOpenCircuitFromPolicy(policy)
// openState := makeOpen(retry.DefaultRetryStatus)(time.Now())
// // openState.resetAt will be approximately 1 second from now
func makeOpenCircuitFromPolicy(policy retry.RetryPolicy) func(rs retry.RetryStatus) func(ct time.Time) openState {
return func(rs retry.RetryStatus) func(ct time.Time) openState {
retryStatus := retry.ApplyPolicy(policy, rs)
return func(ct time.Time) openState {
resetTime := F.Pipe2(
retryStatus,
retry.PreviousDelayLens.Get,
option.Fold(
F.Pipe1(
ct,
lazy.Of,
),
ct.Add,
),
)
return openState{openedAt: ct, resetAt: resetTime, retryStatus: retryStatus}
}
}
}
// extendOpenCircuitFromMakeCircuit creates a function that extends the open state of a circuit breaker
// when a canary request fails. It takes a circuit maker function and returns a function that,
// given the current time, produces an endomorphism that updates an openState.
//
// This function is used when a canary request (test request in half-open state) fails.
// It extends the circuit breaker's open period by:
// 1. Extracting the current retry status from the open state
// 2. Using the makeCircuit function to calculate a new open state with updated retry status
// 3. Applying the current time to get the new state
// 4. Setting the canaryRequest flag to true to allow another test request later
//
// Parameters:
// - makeCircuit: A function that creates an openState from a retry status and current time.
// This is typically created by makeOpenCircuitFromPolicy.
//
// Returns:
// - A curried function that takes:
// 1. ct (time.Time): The current time when extending the circuit
// And returns an Endomorphism[openState] that:
// - Increments the retry count
// - Calculates a new resetAt time based on the retry policy (typically with exponential backoff)
// - Sets canaryRequest to true for the next test attempt
//
// Thread Safety: This is a pure function that returns new openState instances.
// Safe for concurrent use.
//
// Usage Context:
// - Called when a canary request fails in the half-open state
// - Extends the open period with increased backoff delay
// - Prepares the circuit for another canary attempt at the new resetAt time
func extendOpenCircuitFromMakeCircuit(
makeCircuit func(rs retry.RetryStatus) func(ct time.Time) openState,
) func(time.Time) Endomorphism[openState] {
return func(ct time.Time) Endomorphism[openState] {
return F.Flow4(
retryStatusLens.Get,
makeCircuit,
identity.Flap[openState](ct),
testCircuit,
)
}
}
// isResetTimeExceeded checks if the reset time for an open circuit has been exceeded.
// This is used to determine if the circuit breaker should transition from open to half-open state
// by allowing a canary request.
//
// The function returns an option.Kleisli that succeeds (returns Some) only when:
// 1. The circuit is not already in canary mode (canaryRequest is false)
// 2. The current time is after the resetAt time
//
// Parameters:
// - ct: The current time to compare against the reset time
//
// Returns:
// - An option.Kleisli[openState, openState] that:
// - Returns Some(openState) if the reset time has been exceeded and no canary is active
// - Returns None if the reset time has not been exceeded or a canary request is already active
//
// Thread Safety: This is a pure function that does not modify its input.
// Safe for concurrent use.
//
// Usage Context:
// - Called when the circuit is open to check if it's time to attempt a canary request
// - If this returns Some, the circuit transitions to half-open state (canary mode)
// - If this returns None, the circuit remains fully open and requests are blocked
func isResetTimeExceeded(ct time.Time) option.Kleisli[openState, openState] {
return option.FromPredicate(func(open openState) bool {
return !open.canaryRequest && ct.After(resetAtLens.Get(open))
})
}
// handleSuccessOnClosed handles a successful request when the circuit breaker is in closed state.
// It updates the closed state by recording the success and returns an IO operation that
// modifies the breaker state.
//
// This function is part of the circuit breaker's state management for the closed state.
// When a request succeeds in closed state:
// 1. The current time is obtained
// 2. The addSuccess function is called with the current time to update the ClosedState
// 3. The updated ClosedState is wrapped in a Right (closed) BreakerState
// 4. The breaker state is modified with the new state
//
// Parameters:
// - currentTime: An IO operation that provides the current time
// - addSuccess: A Reader that takes a time and returns an endomorphism for ClosedState,
// typically resetting failure counters or history
//
// Returns:
// - An io.Kleisli that takes another io.Kleisli and chains them together.
// The outer Kleisli takes an Endomorphism[BreakerState] and returns BreakerState.
// This allows composing the success handling with other state modifications.
//
// Thread Safety: This function creates IO operations that will atomically modify the
// IORef[BreakerState] when executed. The state modifications are thread-safe.
//
// Type signature:
//
// io.Kleisli[io.Kleisli[Endomorphism[BreakerState], BreakerState], BreakerState]
//
// Usage Context:
// - Called when a request succeeds while the circuit is closed
// - Resets failure tracking (counter or history) in the ClosedState
// - Keeps the circuit in closed state
func handleSuccessOnClosed(
currentTime IO[time.Time],
addSuccess Reader[time.Time, Endomorphism[ClosedState]],
) io.Kleisli[io.Kleisli[Endomorphism[BreakerState], BreakerState], BreakerState] {
return F.Flow2(
io.Chain,
identity.Flap[IO[BreakerState]](F.Pipe1(
currentTime,
io.Map(F.Flow2(
addSuccess,
either.Map[openState],
)))),
)
}
// handleFailureOnClosed handles a failed request when the circuit breaker is in closed state.
// It updates the closed state by recording the failure and checks if the circuit should open.
//
// This function is part of the circuit breaker's state management for the closed state.
// When a request fails in closed state:
// 1. The current time is obtained
// 2. The addError function is called to record the failure in the ClosedState
// 3. The checkClosedState function is called to determine if the failure threshold is exceeded
// 4. If the threshold is exceeded (Check returns None):
// - The circuit transitions to open state using openCircuit
// - A new openState is created with resetAt time calculated from the retry policy
// 5. If the threshold is not exceeded (Check returns Some):
// - The circuit remains closed with the updated failure tracking
//
// Parameters:
// - currentTime: An IO operation that provides the current time
// - addError: A Reader that takes a time and returns an endomorphism for ClosedState,
// recording a failure (incrementing counter or adding to history)
// - checkClosedState: A Reader that takes a time and returns an option.Kleisli that checks
// if the ClosedState should remain closed. Returns Some if circuit stays closed, None if it should open.
// - openCircuit: A Reader that takes a time and returns an openState with calculated resetAt time
//
// Returns:
// - An io.Kleisli that takes another io.Kleisli and chains them together.
// The outer Kleisli takes an Endomorphism[BreakerState] and returns BreakerState.
// This allows composing the failure handling with other state modifications.
//
// Thread Safety: This function creates IO operations that will atomically modify the
// IORef[BreakerState] when executed. The state modifications are thread-safe.
//
// Type signature:
//
// io.Kleisli[io.Kleisli[Endomorphism[BreakerState], BreakerState], BreakerState]
//
// State Transitions:
// - Closed -> Closed: When failure threshold is not exceeded (Some from checkClosedState)
// - Closed -> Open: When failure threshold is exceeded (None from checkClosedState)
//
// Usage Context:
// - Called when a request fails while the circuit is closed
// - Records the failure in the ClosedState (counter or history)
// - May trigger transition to open state if threshold is exceeded
func handleFailureOnClosed(
currentTime IO[time.Time],
addError Reader[time.Time, Endomorphism[ClosedState]],
checkClosedState Reader[time.Time, option.Kleisli[ClosedState, ClosedState]],
openCircuit Reader[time.Time, openState],
) io.Kleisli[io.Kleisli[Endomorphism[BreakerState], BreakerState], BreakerState] {
return F.Flow2(
io.Chain,
identity.Flap[IO[BreakerState]](F.Pipe1(
currentTime,
io.Map(func(ct time.Time) either.Operator[openState, ClosedState, ClosedState] {
return either.Chain(F.Flow3(
addError(ct),
checkClosedState(ct),
option.Fold(
F.Pipe2(
ct,
lazy.Of,
lazy.Map(F.Flow2(
openCircuit,
createOpenCircuit,
)),
),
createClosedCircuit,
),
))
}))),
)
}
// MakeCircuitBreaker creates a circuit breaker implementation for a higher-kinded type.
//
// This is a generic circuit breaker factory that works with any monad-like type (HKTT).
// It implements the circuit breaker pattern by wrapping operations and managing state transitions
// between closed, open, and half-open states based on failure rates and retry policies.
//
// Type Parameters:
// - E: The error type
// - T: The success value type
// - HKTT: The higher-kinded type representing the computation (e.g., IO[T], ReaderIO[R, T])
// - HKTOP: The higher-kinded type for operators (e.g., IO[func(HKTT) HKTT])
// - HKTHKTT: The nested higher-kinded type (e.g., IO[IO[T]])
//
// Parameters:
// - left: Constructs an error result in HKTT from an error value
// - chainFirstIOK: Chains an IO operation that runs after success, preserving the original value
// - chainFirstLeftIOK: Chains an IO operation that runs after error, preserving the original error
// - fromIO: Lifts an IO operation into HKTOP
// - flap: Applies a value to a function wrapped in a higher-kinded type
// - flatten: Flattens nested higher-kinded types (join operation)
// - currentTime: IO operation that provides the current time
// - closedState: The initial closed state configuration
// - makeError: Creates an error from a reset time when the circuit is open
// - checkError: Predicate to determine if an error should trigger circuit breaker logic
// - policy: Retry policy for determining reset times when circuit opens
// - logger: Logging function for circuit breaker events
//
// Thread Safety: The returned State monad creates operations that are thread-safe when
// executed. The IORef[BreakerState] uses atomic operations for all state modifications.
// Multiple concurrent requests will be properly serialized at the IORef level.
//
// Returns:
// - A State monad that transforms a pair of (IORef[BreakerState], HKTT) into HKTT,
// applying circuit breaker logic to the computation
func MakeCircuitBreaker[E, T, HKTT, HKTOP, HKTHKTT any](
left func(E) HKTT,
chainFirstIOK func(io.Kleisli[T, BreakerState]) func(HKTT) HKTT,
chainFirstLeftIOK func(io.Kleisli[E, BreakerState]) func(HKTT) HKTT,
fromIO func(IO[func(HKTT) HKTT]) HKTOP,
flap func(HKTT) func(HKTOP) HKTHKTT,
flatten func(HKTHKTT) HKTT,
currentTime IO[time.Time],
closedState ClosedState,
makeError Reader[time.Time, E],
checkError option.Kleisli[E, E],
policy retry.RetryPolicy,
metrics Metrics,
) State[Pair[IORef[BreakerState], HKTT], HKTT] {
type Operator = func(HKTT) HKTT
addSuccess := reader.From1(ClosedState.AddSuccess)
addError := reader.From1(ClosedState.AddError)
checkClosedState := reader.From1(ClosedState.Check)
closedCircuit := createClosedCircuit(closedState.Empty())
makeOpenCircuit := makeOpenCircuitFromPolicy(policy)
openCircuit := F.Pipe1(
initialRetry,
makeOpenCircuit,
)
extendOpenCircuit := extendOpenCircuitFromMakeCircuit(makeOpenCircuit)
failWithError := F.Flow4(
resetAtLens.Get,
makeError,
left,
reader.Of[HKTT],
)
handleSuccess := handleSuccessOnClosed(currentTime, addSuccess)
handleFailure := handleFailureOnClosed(currentTime, addError, checkClosedState, openCircuit)
onClosed := func(modify io.Kleisli[Endomorphism[BreakerState], BreakerState]) Operator {
return F.Flow2(
// error case
chainFirstLeftIOK(F.Flow3(
checkError,
option.Fold(
// the error is not applicable, handle as success
F.Pipe2(
modify,
handleSuccess,
lazy.Of,
),
// the error is relevant, record it
F.Pipe2(
modify,
handleFailure,
reader.Of[E],
),
),
// metering
io.ChainFirst(either.Fold(
F.Flow2(
openedAtLens.Get,
metrics.Open,
),
func(c ClosedState) IO[Void] {
return io.Of(function.VOID)
},
)),
)),
// good case
chainFirstIOK(F.Pipe2(
modify,
handleSuccess,
reader.Of[T],
)),
)
}
onCanary := func(modify io.Kleisli[Endomorphism[BreakerState], BreakerState]) Operator {
handleSuccess := F.Pipe2(
closedCircuit,
reader.Of[BreakerState],
modify,
)
return F.Flow2(
// the canary request fails
chainFirstLeftIOK(F.Flow2(
checkError,
option.Fold(
// the canary request succeeds, we close the circuit
F.Pipe1(
handleSuccess,
lazy.Of,
),
// the canary request fails, we extend the circuit
F.Pipe1(
F.Pipe1(
currentTime,
io.Chain(func(ct time.Time) IO[BreakerState] {
return F.Pipe1(
F.Flow2(
either.Fold(
extendOpenCircuit(ct),
F.Pipe1(
openCircuit(ct),
reader.Of[ClosedState],
),
),
createOpenCircuit,
),
modify,
)
}),
),
reader.Of[E],
),
),
)),
// the canary request succeeds, we'll close the circuit
chainFirstIOK(F.Pipe1(
handleSuccess,
reader.Of[T],
)),
)
}
onOpen := func(ref IORef[BreakerState]) Operator {
modify := modifyV(ref)
return F.Pipe3(
currentTime,
io.Chain(func(ct time.Time) IO[Operator] {
return F.Pipe1(
ref,
ioref.ModifyWithResult(either.Fold(
func(open openState) Pair[BreakerState, Operator] {
return option.Fold(
func() Pair[BreakerState, Operator] {
return pair.MakePair(createOpenCircuit(open), failWithError(open))
},
func(open openState) Pair[BreakerState, Operator] {
return pair.MakePair(createOpenCircuit(testCircuit(open)), onCanary(modify))
},
)(isResetTimeExceeded(ct)(open))
},
func(closed ClosedState) Pair[BreakerState, Operator] {
return pair.MakePair(createClosedCircuit(closed), onClosed(modify))
},
)),
)
}),
fromIO,
func(src HKTOP) Operator {
return func(rdr HKTT) HKTT {
return F.Pipe2(
src,
flap(rdr),
flatten,
)
}
},
)
}
return func(e Pair[IORef[BreakerState], HKTT]) Pair[Pair[IORef[BreakerState], HKTT], HKTT] {
return pair.MakePair(e, onOpen(pair.Head(e))(pair.Tail(e)))
}
}
// MakeSingletonBreaker creates a singleton circuit breaker operator for a higher-kinded type.
//
// This function creates a circuit breaker that maintains its own internal state reference.
// It's called "singleton" because it creates a single, self-contained circuit breaker instance
// with its own IORef for state management. The returned function can be used to wrap
// computations with circuit breaker protection.
//
// Type Parameters:
// - HKTT: The higher-kinded type representing the computation (e.g., IO[T], ReaderIO[R, T])
//
// Parameters:
// - cb: The circuit breaker State monad created by MakeCircuitBreaker
// - closedState: The initial closed state configuration for the circuit breaker
//
// Returns:
// - A function that wraps a computation (HKTT) with circuit breaker logic.
// The circuit breaker state is managed internally and persists across invocations.
//
// Thread Safety: The returned function is thread-safe. The internal IORef[BreakerState]
// uses atomic operations to manage state. Multiple concurrent calls to the returned function
// will be properly serialized at the state modification level.
//
// Example Usage:
//
// // Create a circuit breaker for IO operations
// breaker := MakeSingletonBreaker(
// MakeCircuitBreaker(...),
// MakeClosedStateCounter(3),
// )
//
// // Use it to wrap operations
// protectedOp := breaker(myIOOperation)
func MakeSingletonBreaker[HKTT any](
cb State[Pair[IORef[BreakerState], HKTT], HKTT],
closedState ClosedState,
) func(HKTT) HKTT {
return F.Flow3(
F.Pipe3(
closedState,
MakeClosedIORef,
io.Run,
pair.FromHead[HKTT],
),
cb,
pair.Tail,
)
}

View File

@@ -0,0 +1,579 @@
package circuitbreaker
import (
"sync"
"testing"
"time"
"github.com/IBM/fp-go/v2/function"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioref"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/retry"
"github.com/stretchr/testify/assert"
)
type testMetrics struct {
accepts int
rejects int
opens int
closes int
canary int
mu sync.Mutex
}
func (m *testMetrics) Accept(_ time.Time) IO[Void] {
return func() Void {
m.mu.Lock()
defer m.mu.Unlock()
m.accepts++
return function.VOID
}
}
func (m *testMetrics) Open(_ time.Time) IO[Void] {
return func() Void {
m.mu.Lock()
defer m.mu.Unlock()
m.opens++
return function.VOID
}
}
func (m *testMetrics) Close(_ time.Time) IO[Void] {
return func() Void {
m.mu.Lock()
defer m.mu.Unlock()
m.closes++
return function.VOID
}
}
func (m *testMetrics) Reject(_ time.Time) IO[Void] {
return func() Void {
m.mu.Lock()
defer m.mu.Unlock()
m.rejects++
return function.VOID
}
}
func (m *testMetrics) Canary(_ time.Time) IO[Void] {
return func() Void {
m.mu.Lock()
defer m.mu.Unlock()
m.canary++
return function.VOID
}
}
// VirtualTimer provides a controllable time source for testing
type VirtualTimer struct {
mu sync.Mutex
current time.Time
}
func NewMockMetrics() Metrics {
return &testMetrics{}
}
// NewVirtualTimer creates a new virtual timer starting at the given time
func NewVirtualTimer(start time.Time) *VirtualTimer {
return &VirtualTimer{current: start}
}
// Now returns the current virtual time
func (vt *VirtualTimer) Now() time.Time {
vt.mu.Lock()
defer vt.mu.Unlock()
return vt.current
}
// Advance moves the virtual time forward by the given duration
func (vt *VirtualTimer) Advance(d time.Duration) {
vt.mu.Lock()
defer vt.mu.Unlock()
vt.current = vt.current.Add(d)
}
// Set sets the virtual time to a specific value
func (vt *VirtualTimer) Set(t time.Time) {
vt.mu.Lock()
defer vt.mu.Unlock()
vt.current = t
}
// TestModifyV tests the modifyV variable
func TestModifyV(t *testing.T) {
t.Run("modifyV creates a Reader that modifies IORef", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
// Create initial state
initialState := createClosedCircuit(MakeClosedStateCounter(3))
ref := io.Run(ioref.MakeIORef(initialState))
// Create an endomorphism that opens the circuit
now := vt.Now()
openState := openState{
openedAt: now,
resetAt: now.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
endomorphism := func(bs BreakerState) BreakerState {
return createOpenCircuit(openState)
}
// Apply modifyV
modifyOp := modifyV(ref)
result := io.Run(modifyOp(endomorphism))
// Verify the state was modified
assert.True(t, IsOpen(result), "state should be open after modification")
})
t.Run("modifyV returns the new state", func(t *testing.T) {
initialState := createClosedCircuit(MakeClosedStateCounter(3))
ref := io.Run(ioref.MakeIORef(initialState))
// Create a simple endomorphism
endomorphism := F.Identity[BreakerState]
modifyOp := modifyV(ref)
result := io.Run(modifyOp(endomorphism))
assert.True(t, IsClosed(result), "state should remain closed")
})
}
// TestTestCircuit tests the testCircuit variable
func TestTestCircuit(t *testing.T) {
t.Run("testCircuit sets canaryRequest to true", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
now := vt.Now()
openState := openState{
openedAt: now,
resetAt: now.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result := testCircuit(openState)
assert.True(t, result.canaryRequest, "canaryRequest should be set to true")
assert.Equal(t, openState.openedAt, result.openedAt, "openedAt should be unchanged")
assert.Equal(t, openState.resetAt, result.resetAt, "resetAt should be unchanged")
})
t.Run("testCircuit is idempotent", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
now := vt.Now()
openState := openState{
openedAt: now,
resetAt: now.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: true, // already true
}
result := testCircuit(openState)
assert.True(t, result.canaryRequest, "canaryRequest should remain true")
})
t.Run("testCircuit preserves other fields", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
now := vt.Now()
resetTime := now.Add(2 * time.Minute)
retryStatus := retry.RetryStatus{
IterNumber: 5,
PreviousDelay: option.Some(30 * time.Second),
}
openState := openState{
openedAt: now,
resetAt: resetTime,
retryStatus: retryStatus,
canaryRequest: false,
}
result := testCircuit(openState)
assert.Equal(t, now, result.openedAt, "openedAt should be preserved")
assert.Equal(t, resetTime, result.resetAt, "resetAt should be preserved")
assert.Equal(t, retryStatus.IterNumber, result.retryStatus.IterNumber, "retryStatus should be preserved")
assert.True(t, result.canaryRequest, "canaryRequest should be set to true")
})
}
// TestMakeOpenCircuitFromPolicy tests the makeOpenCircuitFromPolicy function
func TestMakeOpenCircuitFromPolicy(t *testing.T) {
t.Run("creates openState with calculated reset time", func(t *testing.T) {
policy := retry.LimitRetries(5)
makeOpen := makeOpenCircuitFromPolicy(policy)
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
result := makeOpen(retry.DefaultRetryStatus)(currentTime)
assert.Equal(t, currentTime, result.openedAt, "openedAt should be current time")
assert.False(t, result.canaryRequest, "canaryRequest should be false initially")
assert.NotNil(t, result.retryStatus, "retryStatus should be set")
})
t.Run("applies retry policy to calculate delay", func(t *testing.T) {
// Use exponential backoff policy with limit and cap
policy := retry.Monoid.Concat(
retry.LimitRetries(10),
retry.CapDelay(10*time.Second, retry.ExponentialBackoff(1*time.Second)),
)
makeOpen := makeOpenCircuitFromPolicy(policy)
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
// First retry (iter 0)
result1 := makeOpen(retry.DefaultRetryStatus)(currentTime)
// The first delay should be approximately 1 second
expectedResetTime1 := currentTime.Add(1 * time.Second)
assert.WithinDuration(t, expectedResetTime1, result1.resetAt, 100*time.Millisecond,
"first reset time should be ~1 second from now")
// Second retry (iter 1) - should double
result2 := makeOpen(result1.retryStatus)(currentTime)
expectedResetTime2 := currentTime.Add(2 * time.Second)
assert.WithinDuration(t, expectedResetTime2, result2.resetAt, 100*time.Millisecond,
"second reset time should be ~2 seconds from now")
})
t.Run("handles first failure with no previous delay", func(t *testing.T) {
policy := retry.LimitRetries(3)
makeOpen := makeOpenCircuitFromPolicy(policy)
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
result := makeOpen(retry.DefaultRetryStatus)(currentTime)
// With no previous delay, resetAt should be current time
assert.Equal(t, currentTime, result.resetAt, "resetAt should be current time when no previous delay")
})
t.Run("increments retry iteration number", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
policy := retry.LimitRetries(10)
makeOpen := makeOpenCircuitFromPolicy(policy)
currentTime := vt.Now()
initialStatus := retry.DefaultRetryStatus
result := makeOpen(initialStatus)(currentTime)
assert.Greater(t, result.retryStatus.IterNumber, initialStatus.IterNumber,
"retry iteration should be incremented")
})
t.Run("curried function can be partially applied", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
policy := retry.LimitRetries(5)
makeOpen := makeOpenCircuitFromPolicy(policy)
// Partially apply with retry status
makeOpenWithStatus := makeOpen(retry.DefaultRetryStatus)
currentTime := vt.Now()
result := makeOpenWithStatus(currentTime)
assert.NotNil(t, result, "partially applied function should work")
assert.Equal(t, currentTime, result.openedAt)
})
}
// TestExtendOpenCircuitFromMakeCircuit tests the extendOpenCircuitFromMakeCircuit function
func TestExtendOpenCircuitFromMakeCircuit(t *testing.T) {
t.Run("extends open circuit with new retry status", func(t *testing.T) {
policy := retry.Monoid.Concat(
retry.LimitRetries(10),
retry.ExponentialBackoff(1*time.Second),
)
makeCircuit := makeOpenCircuitFromPolicy(policy)
extendCircuit := extendOpenCircuitFromMakeCircuit(makeCircuit)
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
// Create initial open state
initialOpen := openState{
openedAt: currentTime.Add(-1 * time.Minute),
resetAt: currentTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
// Extend the circuit
extendOp := extendCircuit(currentTime)
result := extendOp(initialOpen)
assert.True(t, result.canaryRequest, "canaryRequest should be set to true")
assert.Greater(t, result.retryStatus.IterNumber, initialOpen.retryStatus.IterNumber,
"retry iteration should be incremented")
assert.True(t, result.resetAt.After(currentTime), "resetAt should be in the future")
})
t.Run("sets canaryRequest to true for next test", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
policy := retry.LimitRetries(5)
makeCircuit := makeOpenCircuitFromPolicy(policy)
extendCircuit := extendOpenCircuitFromMakeCircuit(makeCircuit)
currentTime := vt.Now()
initialOpen := openState{
openedAt: currentTime.Add(-30 * time.Second),
resetAt: currentTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result := extendCircuit(currentTime)(initialOpen)
assert.True(t, result.canaryRequest, "canaryRequest must be true after extension")
})
t.Run("applies exponential backoff on successive extensions", func(t *testing.T) {
policy := retry.Monoid.Concat(
retry.LimitRetries(10),
retry.ExponentialBackoff(1*time.Second),
)
makeCircuit := makeOpenCircuitFromPolicy(policy)
extendCircuit := extendOpenCircuitFromMakeCircuit(makeCircuit)
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
// First extension
state1 := openState{
openedAt: currentTime,
resetAt: currentTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result1 := extendCircuit(currentTime)(state1)
delay1 := result1.resetAt.Sub(currentTime)
// Second extension (should have longer delay)
result2 := extendCircuit(currentTime)(result1)
delay2 := result2.resetAt.Sub(currentTime)
assert.Greater(t, delay2, delay1, "second extension should have longer delay due to exponential backoff")
})
}
// TestIsResetTimeExceeded tests the isResetTimeExceeded function
func TestIsResetTimeExceeded(t *testing.T) {
t.Run("returns Some when reset time is exceeded and no canary active", func(t *testing.T) {
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
resetTime := currentTime.Add(-1 * time.Second) // in the past
openState := openState{
openedAt: currentTime.Add(-1 * time.Minute),
resetAt: resetTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result := isResetTimeExceeded(currentTime)(openState)
assert.True(t, option.IsSome(result), "should return Some when reset time exceeded")
})
t.Run("returns None when reset time not yet exceeded", func(t *testing.T) {
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
resetTime := currentTime.Add(1 * time.Minute) // in the future
openState := openState{
openedAt: currentTime.Add(-30 * time.Second),
resetAt: resetTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result := isResetTimeExceeded(currentTime)(openState)
assert.True(t, option.IsNone(result), "should return None when reset time not exceeded")
})
t.Run("returns None when canary request is already active", func(t *testing.T) {
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
resetTime := currentTime.Add(-1 * time.Second) // in the past
openState := openState{
openedAt: currentTime.Add(-1 * time.Minute),
resetAt: resetTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: true, // canary already active
}
result := isResetTimeExceeded(currentTime)(openState)
assert.True(t, option.IsNone(result), "should return None when canary is already active")
})
t.Run("returns Some at exact reset time boundary", func(t *testing.T) {
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
resetTime := currentTime.Add(-1 * time.Nanosecond) // just passed
openState := openState{
openedAt: currentTime.Add(-1 * time.Minute),
resetAt: resetTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result := isResetTimeExceeded(currentTime)(openState)
assert.True(t, option.IsSome(result), "should return Some when current time is after reset time")
})
t.Run("returns None when current time equals reset time", func(t *testing.T) {
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
resetTime := currentTime // exactly equal
openState := openState{
openedAt: currentTime.Add(-1 * time.Minute),
resetAt: resetTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result := isResetTimeExceeded(currentTime)(openState)
assert.True(t, option.IsNone(result), "should return None when times are equal (not After)")
})
}
// TestHandleSuccessOnClosed tests the handleSuccessOnClosed function
func TestHandleSuccessOnClosed(t *testing.T) {
t.Run("resets failure count on success", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now
addSuccess := reader.From1(ClosedState.AddSuccess)
// Create initial state with some failures
now := vt.Now()
initialClosed := MakeClosedStateCounter(3)
initialClosed = initialClosed.AddError(now)
initialClosed = initialClosed.AddError(now)
initialState := createClosedCircuit(initialClosed)
ref := io.Run(ioref.MakeIORef(initialState))
modify := modifyV(ref)
handler := handleSuccessOnClosed(currentTime, addSuccess)
// Apply the handler
result := io.Run(handler(modify))
// Verify state is still closed and failures are reset
assert.True(t, IsClosed(result), "circuit should remain closed after success")
})
t.Run("keeps circuit closed", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now
addSuccess := reader.From1(ClosedState.AddSuccess)
initialState := createClosedCircuit(MakeClosedStateCounter(3))
ref := io.Run(ioref.MakeIORef(initialState))
modify := modifyV(ref)
handler := handleSuccessOnClosed(currentTime, addSuccess)
result := io.Run(handler(modify))
assert.True(t, IsClosed(result), "circuit should remain closed")
})
}
// TestHandleFailureOnClosed tests the handleFailureOnClosed function
func TestHandleFailureOnClosed(t *testing.T) {
t.Run("keeps circuit closed when threshold not exceeded", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now
addError := reader.From1(ClosedState.AddError)
checkClosedState := reader.From1(ClosedState.Check)
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
// Create initial state with room for more failures
now := vt.Now()
initialClosed := MakeClosedStateCounter(5) // threshold is 5
initialClosed = initialClosed.AddError(now)
initialState := createClosedCircuit(initialClosed)
ref := io.Run(ioref.MakeIORef(initialState))
modify := modifyV(ref)
handler := handleFailureOnClosed(currentTime, addError, checkClosedState, openCircuit)
result := io.Run(handler(modify))
assert.True(t, IsClosed(result), "circuit should remain closed when threshold not exceeded")
})
t.Run("opens circuit when threshold exceeded", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now
addError := reader.From1(ClosedState.AddError)
checkClosedState := reader.From1(ClosedState.Check)
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
// Create initial state at threshold
now := vt.Now()
initialClosed := MakeClosedStateCounter(2) // threshold is 2
initialClosed = initialClosed.AddError(now)
initialState := createClosedCircuit(initialClosed)
ref := io.Run(ioref.MakeIORef(initialState))
modify := modifyV(ref)
handler := handleFailureOnClosed(currentTime, addError, checkClosedState, openCircuit)
result := io.Run(handler(modify))
assert.True(t, IsOpen(result), "circuit should open when threshold exceeded")
})
t.Run("records failure in closed state", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now
addError := reader.From1(ClosedState.AddError)
checkClosedState := reader.From1(ClosedState.Check)
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
initialState := createClosedCircuit(MakeClosedStateCounter(10))
ref := io.Run(ioref.MakeIORef(initialState))
modify := modifyV(ref)
handler := handleFailureOnClosed(currentTime, addError, checkClosedState, openCircuit)
result := io.Run(handler(modify))
// Should still be closed but with failure recorded
assert.True(t, IsClosed(result), "circuit should remain closed")
})
}

329
v2/circuitbreaker/closed.go Normal file
View File

@@ -0,0 +1,329 @@
package circuitbreaker
import (
"slices"
"time"
A "github.com/IBM/fp-go/v2/array"
F "github.com/IBM/fp-go/v2/function"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/ord"
)
type (
// ClosedState represents the closed state of a circuit breaker.
// In the closed state, requests are allowed to pass through, but failures are tracked.
// If a failure condition is met, the circuit breaker transitions to an open state.
//
// # Thread Safety
//
// All ClosedState implementations MUST be thread-safe. The recommended approach is to
// make all methods return new copies rather than modifying the receiver, which provides
// automatic thread safety through immutability.
//
// Implementations should ensure that:
// - Empty() returns a new instance with cleared state
// - AddError() returns a new instance with the error recorded
// - AddSuccess() returns a new instance with success recorded
// - Check() does not modify the receiver
//
// Both provided implementations (closedStateWithErrorCount and closedStateWithHistory)
// follow this pattern and are safe for concurrent use.
ClosedState interface {
// Empty returns a new ClosedState with all tracked failures cleared.
// This is used when transitioning back to a closed state from an open state.
//
// Thread Safety: Returns a new instance; safe for concurrent use.
Empty() ClosedState
// AddError records a failure at the given time.
// Returns an updated ClosedState reflecting the recorded failure.
//
// Thread Safety: Returns a new instance; safe for concurrent use.
// The original ClosedState is not modified.
AddError(time.Time) ClosedState
// AddSuccess records a successful request at the given time.
// Returns an updated ClosedState reflecting the successful request.
//
// Thread Safety: Returns a new instance; safe for concurrent use.
// The original ClosedState is not modified.
AddSuccess(time.Time) ClosedState
// Check verifies if the circuit breaker should remain closed at the given time.
// Returns Some(ClosedState) if the circuit should stay closed,
// or None if the circuit should open due to exceeding the failure threshold.
//
// Thread Safety: Does not modify the receiver; safe for concurrent use.
Check(time.Time) Option[ClosedState]
}
// closedStateWithErrorCount is a counter-based implementation of ClosedState.
// It tracks the number of consecutive failures and opens the circuit when
// the failure count exceeds a configured threshold.
//
// Thread Safety: This implementation is immutable. All methods return new instances
// rather than modifying the receiver, making it safe for concurrent use without locks.
closedStateWithErrorCount struct {
// checkFailures is a Kleisli arrow that checks if the failure count exceeds the threshold.
// Returns Some(count) if threshold is exceeded, None otherwise.
checkFailures option.Kleisli[uint, uint]
// failureCount tracks the current number of consecutive failures.
failureCount uint
}
// closedStateWithHistory is a time-window-based implementation of ClosedState.
// It tracks failures within a sliding time window and opens the circuit when
// the failure count within the window exceeds a configured threshold.
//
// Thread Safety: This implementation is immutable. All methods return new instances
// with new slices rather than modifying the receiver, making it safe for concurrent
// use without locks. The history slice is never modified in place; addToSlice always
// creates a new slice.
closedStateWithHistory struct {
ordTime Ord[time.Time]
// maxFailures is the maximum number of failures allowed within the time window.
checkFailures option.Kleisli[int, int]
timeWindow time.Duration
history []time.Time
}
)
var (
failureCountLens = lens.MakeLensStrictWithName(
func(s *closedStateWithErrorCount) uint { return s.failureCount },
func(s *closedStateWithErrorCount, c uint) *closedStateWithErrorCount {
s.failureCount = c
return s
},
"closeStateWithErrorCount.failureCount",
)
historyLens = lens.MakeLensRefWithName(
func(s *closedStateWithHistory) []time.Time { return s.history },
func(s *closedStateWithHistory, c []time.Time) *closedStateWithHistory {
s.history = c
return s
},
"closedStateWithHistory.history",
)
resetHistory = historyLens.Set(A.Empty[time.Time]())
resetFailureCount = failureCountLens.Set(0)
incFailureCount = lens.Modify[*closedStateWithErrorCount](N.Add(uint(1)))(failureCountLens)
)
// Empty returns a new closedStateWithErrorCount with the failure count reset to zero.
//
// Thread Safety: Returns a new instance; the original is not modified.
// Safe for concurrent use.
func (s *closedStateWithErrorCount) Empty() ClosedState {
return resetFailureCount(s)
}
// AddError increments the failure count and returns a new closedStateWithErrorCount.
// The time parameter is ignored in this counter-based implementation.
//
// Thread Safety: Returns a new instance; the original is not modified.
// Safe for concurrent use.
func (s *closedStateWithErrorCount) AddError(_ time.Time) ClosedState {
return incFailureCount(s)
}
// AddSuccess resets the failure count to zero and returns a new closedStateWithErrorCount.
// The time parameter is ignored in this counter-based implementation.
//
// Thread Safety: Returns a new instance; the original is not modified.
// Safe for concurrent use.
func (s *closedStateWithErrorCount) AddSuccess(_ time.Time) ClosedState {
return resetFailureCount(s)
}
// Check verifies if the failure count is below the threshold.
// Returns Some(ClosedState) if below threshold, None if at or above threshold.
// The time parameter is ignored in this counter-based implementation.
//
// Thread Safety: Does not modify the receiver; safe for concurrent use.
func (s *closedStateWithErrorCount) Check(_ time.Time) Option[ClosedState] {
return F.Pipe3(
s,
failureCountLens.Get,
s.checkFailures,
option.MapTo[uint](ClosedState(s)),
)
}
// MakeClosedStateCounter creates a counter-based ClosedState implementation.
// The circuit breaker will open when the number of consecutive failures reaches maxFailures.
//
// Parameters:
// - maxFailures: The threshold for consecutive failures. The circuit opens when
// failureCount >= maxFailures (greater than or equal to).
//
// Returns:
// - A ClosedState that tracks failures using a simple counter.
//
// Example:
// - If maxFailures is 3, the circuit will open on the 3rd consecutive failure.
// - Each AddError call increments the counter.
// - Each AddSuccess call resets the counter to 0 (only consecutive failures count).
// - Empty resets the counter to 0.
//
// Behavior:
// - Check returns Some(ClosedState) when failureCount < maxFailures (circuit stays closed)
// - Check returns None when failureCount >= maxFailures (circuit should open)
// - AddSuccess resets the failure count, so only consecutive failures trigger circuit opening
//
// Thread Safety: The returned ClosedState is safe for concurrent use. All methods
// return new instances rather than modifying the receiver.
func MakeClosedStateCounter(maxFailures uint) ClosedState {
return &closedStateWithErrorCount{
checkFailures: option.FromPredicate(N.LessThan(maxFailures)),
}
}
// Empty returns a new closedStateWithHistory with an empty failure history.
//
// Thread Safety: Returns a new instance with a new empty slice; the original is not modified.
// Safe for concurrent use.
func (s *closedStateWithHistory) Empty() ClosedState {
return resetHistory(s)
}
// addToSlice creates a new sorted slice by adding an item to an existing slice.
// This function does not modify the input slice; it creates a new slice with the item added
// and returns it in sorted order.
//
// Parameters:
// - o: An Ord instance for comparing time.Time values to determine sort order
// - ar: The existing slice of time.Time values (assumed to be sorted)
// - item: The new time.Time value to add to the slice
//
// Returns:
// - A new slice containing all elements from ar plus the new item, sorted in ascending order
//
// Implementation Details:
// - Creates a new slice with capacity len(ar)+1
// - Copies all elements from ar to the new slice
// - Appends the new item
// - Sorts the entire slice using the provided Ord comparator
//
// Thread Safety: This function is pure and does not modify its inputs. It always returns
// a new slice, making it safe for concurrent use. This is a key component of the immutable
// design of closedStateWithHistory.
//
// Note: This function is used internally by closedStateWithHistory.AddError to maintain
// a sorted history of failure timestamps for efficient binary search operations.
func addToSlice(o ord.Ord[time.Time], ar []time.Time, item time.Time) []time.Time {
cpy := make([]time.Time, len(ar)+1)
cpy[copy(cpy, ar)] = item
slices.SortFunc(cpy, o.Compare)
return cpy
}
// AddError records a failure at the given time and returns a new closedStateWithHistory.
// The new instance contains the failure in its history, with old failures outside the
// time window automatically pruned.
//
// Thread Safety: Returns a new instance with a new history slice; the original is not modified.
// Safe for concurrent use. The addToSlice function creates a new slice, ensuring immutability.
func (s *closedStateWithHistory) AddError(currentTime time.Time) ClosedState {
addFailureToHistory := F.Pipe1(
historyLens,
lens.Modify[*closedStateWithHistory](func(old []time.Time) []time.Time {
// oldest valid entry
idx, _ := slices.BinarySearchFunc(old, currentTime.Add(-s.timeWindow), s.ordTime.Compare)
return addToSlice(s.ordTime, old[idx:], currentTime)
}),
)
return addFailureToHistory(s)
}
// AddSuccess purges the entire failure history and returns a new closedStateWithHistory.
// The time parameter is ignored; any success clears all tracked failures.
//
// Thread Safety: Returns a new instance with a new empty slice; the original is not modified.
// Safe for concurrent use.
func (s *closedStateWithHistory) AddSuccess(_ time.Time) ClosedState {
return resetHistory(s)
}
// Check verifies if the number of failures in the history is below the threshold.
// Returns Some(ClosedState) if below threshold, None if at or above threshold.
// The time parameter is ignored; the check is based on the current history size.
//
// Thread Safety: Does not modify the receiver; safe for concurrent use.
func (s *closedStateWithHistory) Check(_ time.Time) Option[ClosedState] {
return F.Pipe4(
s,
historyLens.Get,
A.Size,
s.checkFailures,
option.MapTo[int](ClosedState(s)),
)
}
// MakeClosedStateHistory creates a time-window-based ClosedState implementation.
// The circuit breaker will open when the number of failures within a sliding time window reaches maxFailures.
//
// Unlike MakeClosedStateCounter which tracks consecutive failures, this implementation tracks
// all failures within a time window. However, any successful request will purge the entire history,
// effectively resetting the failure tracking.
//
// Parameters:
// - timeWindow: The duration of the sliding time window. Failures older than this are automatically
// discarded from the history when new failures are added.
// - maxFailures: The threshold for failures within the time window. The circuit opens when
// the number of failures in the window reaches this value (failureCount >= maxFailures).
//
// Returns:
// - A ClosedState that tracks failures using a time-based sliding window.
//
// Example:
// - If timeWindow is 1 minute and maxFailures is 5, the circuit will open when 5 failures
// occur within any 1-minute period.
// - Failures older than 1 minute are automatically removed from the history when AddError is called.
// - Any successful request immediately purges all tracked failures from the history.
//
// Behavior:
// - AddError records the failure timestamp and removes failures outside the time window
// (older than currentTime - timeWindow).
// - AddSuccess purges the entire failure history (all tracked failures are removed).
// - Check returns Some(ClosedState) when failureCount < maxFailures (circuit stays closed).
// - Check returns None when failureCount >= maxFailures (circuit should open).
// - Empty purges the entire failure history.
//
// Time Window Management:
// - The history is automatically pruned on each AddError call to remove failures older than
// currentTime - timeWindow.
// - The history is kept sorted by time for efficient binary search and pruning.
//
// Important Note:
// - A successful request resets everything by purging the entire history. This means that
// unlike a pure sliding window, a single success will clear all tracked failures, even
// those within the time window. This behavior is similar to MakeClosedStateCounter but
// with time-based tracking for failures.
//
// Thread Safety: The returned ClosedState is safe for concurrent use. All methods return
// new instances with new slices rather than modifying the receiver. The history slice is
// never modified in place.
//
// Use Cases:
// - Systems where a successful request indicates recovery and past failures should be forgotten.
// - Rate limiting with success-based reset: Allow bursts of failures but reset on success.
// - Hybrid approach: Time-based failure tracking with success-based recovery.
func MakeClosedStateHistory(
timeWindow time.Duration,
maxFailures uint) ClosedState {
return &closedStateWithHistory{
checkFailures: option.FromPredicate(N.LessThan(int(maxFailures))),
ordTime: ord.OrdTime(),
history: A.Empty[time.Time](),
timeWindow: timeWindow,
}
}

View File

@@ -0,0 +1,934 @@
package circuitbreaker
import (
"testing"
"time"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/ord"
"github.com/stretchr/testify/assert"
)
func TestMakeClosedStateCounter(t *testing.T) {
t.Run("creates a valid ClosedState", func(t *testing.T) {
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
assert.NotNil(t, state, "MakeClosedStateCounter should return a non-nil ClosedState")
})
t.Run("initial state passes Check", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
result := state.Check(now)
assert.True(t, option.IsSome(result), "initial state should pass Check (return Some, circuit stays closed)")
})
t.Run("Empty resets failure count", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(2)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Add some errors
state = state.AddError(now)
state = state.AddError(now)
// Reset the state
state = state.Empty()
// Should pass check after reset
result := state.Check(now)
assert.True(t, option.IsSome(result), "state should pass Check after Empty")
})
t.Run("AddSuccess resets failure count", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
// Add errors
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
// Add success (should reset counter)
state = state.AddSuccess(vt.Now())
vt.Advance(1 * time.Second)
// Add another error (this is now the first consecutive error)
state = state.AddError(vt.Now())
// Should still pass check (only 1 consecutive error, threshold is 3)
result := state.Check(vt.Now())
assert.True(t, option.IsSome(result), "AddSuccess should reset failure count")
})
t.Run("circuit opens when failures reach threshold", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Add errors up to but not including threshold
state = state.AddError(now)
state = state.AddError(now)
// Should still pass before threshold
result := state.Check(now)
assert.True(t, option.IsSome(result), "should pass Check before threshold")
// Add one more error to reach threshold
state = state.AddError(now)
// Should fail check at threshold
result = state.Check(now)
assert.True(t, option.IsNone(result), "should fail Check when reaching threshold")
})
t.Run("circuit opens exactly at maxFailures", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(5)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Add exactly maxFailures - 1 errors
for i := uint(0); i < maxFailures-1; i++ {
state = state.AddError(now)
}
// Should still pass
result := state.Check(now)
assert.True(t, option.IsSome(result), "should pass Check before maxFailures")
// Add one more to reach maxFailures
state = state.AddError(now)
// Should fail now
result = state.Check(now)
assert.True(t, option.IsNone(result), "should fail Check at maxFailures")
})
t.Run("zero maxFailures means circuit is always open", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(0)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Initial state should already fail (0 >= 0)
result := state.Check(now)
assert.True(t, option.IsNone(result), "initial state should fail Check with maxFailures=0")
// Add one error
state = state.AddError(now)
// Should still fail
result = state.Check(now)
assert.True(t, option.IsNone(result), "should fail Check after error with maxFailures=0")
})
t.Run("AddSuccess resets counter between errors", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
// Add errors
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
// Add success (resets counter)
state = state.AddSuccess(vt.Now())
vt.Advance(1 * time.Second)
// Add more errors
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
// Should still pass (only 2 consecutive errors after reset)
result := state.Check(vt.Now())
assert.True(t, option.IsSome(result), "should pass with 2 consecutive errors after reset")
// Add one more to reach threshold
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
// Should fail at threshold
result = state.Check(vt.Now())
assert.True(t, option.IsNone(result), "should fail after reaching threshold")
})
t.Run("Empty can be called multiple times", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(2)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Add errors
state = state.AddError(now)
state = state.AddError(now)
state = state.AddError(now)
// Reset multiple times
state = state.Empty()
state = state.Empty()
state = state.Empty()
// Should still pass
result := state.Check(now)
assert.True(t, option.IsSome(result), "state should pass Check after multiple Empty calls")
})
t.Run("time parameter is ignored in counter implementation", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
// Use different times for each operation
time1 := vt.Now()
time2 := time1.Add(1 * time.Hour)
state = state.AddError(time1)
state = state.AddError(time2)
// Check with yet another time
time3 := time1.Add(2 * time.Hour)
result := state.Check(time3)
// Should still pass (2 errors, threshold is 3, not reached yet)
assert.True(t, option.IsSome(result), "time parameter should not affect counter behavior")
// Add one more to reach threshold
state = state.AddError(time1)
result = state.Check(time1)
assert.True(t, option.IsNone(result), "should fail after reaching threshold regardless of time")
})
t.Run("large maxFailures value", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(1000)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Add many errors but not reaching threshold
for i := uint(0); i < maxFailures-1; i++ {
state = state.AddError(now)
}
// Should still pass
result := state.Check(now)
assert.True(t, option.IsSome(result), "should pass Check with large maxFailures before threshold")
// Add one more to reach threshold
state = state.AddError(now)
// Should fail
result = state.Check(now)
assert.True(t, option.IsNone(result), "should fail Check with large maxFailures at threshold")
})
t.Run("state is immutable - original unchanged after AddError", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(2)
originalState := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Create new state by adding error
newState := originalState.AddError(now)
// Original should still pass check
result := originalState.Check(now)
assert.True(t, option.IsSome(result), "original state should be unchanged")
// New state should reach threshold (2 errors total, threshold is 2)
newState = newState.AddError(now)
result = newState.Check(now)
assert.True(t, option.IsNone(result), "new state should fail after reaching threshold")
// Original should still pass
result = originalState.Check(now)
assert.True(t, option.IsSome(result), "original state should still be unchanged")
})
t.Run("state is immutable - original unchanged after Empty", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(2)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Add errors to original
state = state.AddError(now)
state = state.AddError(now)
stateWithErrors := state
// Create new state by calling Empty
emptyState := stateWithErrors.Empty()
// Original with errors should reach threshold (2 errors total, threshold is 2)
result := stateWithErrors.Check(now)
assert.True(t, option.IsNone(result), "state with errors should fail after reaching threshold")
// Empty state should pass
result = emptyState.Check(now)
assert.True(t, option.IsSome(result), "empty state should pass Check")
})
t.Run("AddSuccess prevents circuit from opening", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
// Add errors close to threshold
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
// Add success before reaching threshold
state = state.AddSuccess(vt.Now())
vt.Advance(1 * time.Second)
// Add more errors
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
// Should still pass (only 2 consecutive errors)
result := state.Check(vt.Now())
assert.True(t, option.IsSome(result), "circuit should stay closed after success reset")
})
t.Run("multiple AddSuccess calls keep counter at zero", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(2)
state := MakeClosedStateCounter(maxFailures)
// Add error
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
// Multiple successes
state = state.AddSuccess(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddSuccess(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddSuccess(vt.Now())
vt.Advance(1 * time.Second)
// Should still pass
result := state.Check(vt.Now())
assert.True(t, option.IsSome(result), "multiple AddSuccess should keep counter at zero")
// Add errors to reach threshold
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
// Should fail
result = state.Check(vt.Now())
assert.True(t, option.IsNone(result), "should fail after reaching threshold")
})
t.Run("alternating errors and successes never opens circuit", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
// Alternate errors and successes
for i := 0; i < 10; i++ {
state = state.AddError(vt.Now())
vt.Advance(500 * time.Millisecond)
state = state.AddSuccess(vt.Now())
vt.Advance(500 * time.Millisecond)
}
// Should still pass (never had consecutive failures)
result := state.Check(vt.Now())
assert.True(t, option.IsSome(result), "alternating errors and successes should never open circuit")
})
}
func TestAddToSlice(t *testing.T) {
ordTime := ord.OrdTime()
t.Run("adds item to empty slice and returns sorted result", func(t *testing.T) {
input := []time.Time{}
item := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 1, "result should have 1 element")
assert.Equal(t, item, result[0], "result should contain the added item")
})
t.Run("adds item and maintains sorted order", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
input := []time.Time{
baseTime,
baseTime.Add(20 * time.Second),
baseTime.Add(40 * time.Second),
}
item := baseTime.Add(30 * time.Second)
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 4, "result should have 4 elements")
// Verify sorted order
assert.Equal(t, baseTime, result[0])
assert.Equal(t, baseTime.Add(20*time.Second), result[1])
assert.Equal(t, baseTime.Add(30*time.Second), result[2])
assert.Equal(t, baseTime.Add(40*time.Second), result[3])
})
t.Run("adds item at beginning when it's earliest", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
input := []time.Time{
baseTime.Add(20 * time.Second),
baseTime.Add(40 * time.Second),
}
item := baseTime
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 3, "result should have 3 elements")
assert.Equal(t, baseTime, result[0], "earliest item should be first")
assert.Equal(t, baseTime.Add(20*time.Second), result[1])
assert.Equal(t, baseTime.Add(40*time.Second), result[2])
})
t.Run("adds item at end when it's latest", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
input := []time.Time{
baseTime,
baseTime.Add(20 * time.Second),
}
item := baseTime.Add(40 * time.Second)
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 3, "result should have 3 elements")
assert.Equal(t, baseTime, result[0])
assert.Equal(t, baseTime.Add(20*time.Second), result[1])
assert.Equal(t, baseTime.Add(40*time.Second), result[2], "latest item should be last")
})
t.Run("does not modify original slice", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
input := []time.Time{
baseTime,
baseTime.Add(20 * time.Second),
}
originalLen := len(input)
originalFirst := input[0]
originalLast := input[1]
item := baseTime.Add(10 * time.Second)
result := addToSlice(ordTime, input, item)
// Verify original slice is unchanged
assert.Len(t, input, originalLen, "original slice length should be unchanged")
assert.Equal(t, originalFirst, input[0], "original slice first element should be unchanged")
assert.Equal(t, originalLast, input[1], "original slice last element should be unchanged")
// Verify result is different and has correct length
assert.Len(t, result, 3, "result should have new length")
// Verify the result contains the new item in sorted order
assert.Equal(t, baseTime, result[0])
assert.Equal(t, baseTime.Add(10*time.Second), result[1])
assert.Equal(t, baseTime.Add(20*time.Second), result[2])
})
t.Run("handles duplicate timestamps", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
input := []time.Time{
baseTime,
baseTime.Add(20 * time.Second),
}
item := baseTime // duplicate of first element
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 3, "result should have 3 elements including duplicate")
// Both instances of baseTime should be present
count := 0
for _, t := range result {
if t.Equal(baseTime) {
count++
}
}
assert.Equal(t, 2, count, "should have 2 instances of the duplicate timestamp")
})
t.Run("maintains sort order with unsorted input", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Input is intentionally unsorted
input := []time.Time{
baseTime.Add(40 * time.Second),
baseTime,
baseTime.Add(20 * time.Second),
}
item := baseTime.Add(30 * time.Second)
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 4, "result should have 4 elements")
// Verify result is sorted regardless of input order
for i := 0; i < len(result)-1; i++ {
assert.True(t, result[i].Before(result[i+1]) || result[i].Equal(result[i+1]),
"result should be sorted: element %d (%v) should be <= element %d (%v)",
i, result[i], i+1, result[i+1])
}
})
t.Run("works with nanosecond precision", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
input := []time.Time{
baseTime,
baseTime.Add(2 * time.Nanosecond),
}
item := baseTime.Add(1 * time.Nanosecond)
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 3, "result should have 3 elements")
assert.Equal(t, baseTime, result[0])
assert.Equal(t, baseTime.Add(1*time.Nanosecond), result[1])
assert.Equal(t, baseTime.Add(2*time.Nanosecond), result[2])
})
}
func TestMakeClosedStateHistory(t *testing.T) {
t.Run("creates a valid ClosedState", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
assert.NotNil(t, state, "MakeClosedStateHistory should return a non-nil ClosedState")
})
t.Run("initial state passes Check", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
result := state.Check(now)
assert.True(t, option.IsSome(result), "initial state should pass Check (return Some, circuit stays closed)")
})
t.Run("Empty purges failure history", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(2)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add some errors
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
// Reset the state
state = state.Empty()
// Should pass check after reset
result := state.Check(baseTime.Add(20 * time.Second))
assert.True(t, option.IsSome(result), "state should pass Check after Empty")
})
t.Run("AddSuccess purges entire failure history", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
// Add success (should purge all history)
state = state.AddSuccess(baseTime.Add(20 * time.Second))
// Add another error (this is now the first error in history)
state = state.AddError(baseTime.Add(30 * time.Second))
// Should still pass check (only 1 error in history, threshold is 3)
result := state.Check(baseTime.Add(30 * time.Second))
assert.True(t, option.IsSome(result), "AddSuccess should purge entire failure history")
})
t.Run("circuit opens when failures reach threshold within time window", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors within time window but not reaching threshold
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
// Should still pass before threshold
result := state.Check(baseTime.Add(20 * time.Second))
assert.True(t, option.IsSome(result), "should pass Check before threshold")
// Add one more error to reach threshold
state = state.AddError(baseTime.Add(30 * time.Second))
// Should fail check at threshold
result = state.Check(baseTime.Add(30 * time.Second))
assert.True(t, option.IsNone(result), "should fail Check when reaching threshold")
})
t.Run("old failures outside time window are automatically removed", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors that will be outside the time window
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
// Add error after time window has passed (this should remove old errors)
state = state.AddError(baseTime.Add(2 * time.Minute))
// Should pass check (only 1 error in window, old ones removed)
result := state.Check(baseTime.Add(2 * time.Minute))
assert.True(t, option.IsSome(result), "old failures should be removed from history")
})
t.Run("failures within time window are retained", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors within time window
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(30 * time.Second))
state = state.AddError(baseTime.Add(50 * time.Second))
// All errors are within 1 minute window, should fail check
result := state.Check(baseTime.Add(50 * time.Second))
assert.True(t, option.IsNone(result), "failures within time window should be retained")
})
t.Run("sliding window behavior - errors slide out over time", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add 3 errors to reach threshold
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
state = state.AddError(baseTime.Add(20 * time.Second))
// Circuit should be open
result := state.Check(baseTime.Add(20 * time.Second))
assert.True(t, option.IsNone(result), "circuit should be open with 3 failures")
// Add error after first failure has expired (> 1 minute from first error)
// This should remove the first error, leaving only 3 in window
state = state.AddError(baseTime.Add(70 * time.Second))
// Should still fail check (3 errors in window after pruning)
result = state.Check(baseTime.Add(70 * time.Second))
assert.True(t, option.IsNone(result), "circuit should remain open with 3 failures in window")
})
t.Run("zero maxFailures means circuit is always open", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(0)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Initial state should already fail (0 >= 0)
result := state.Check(baseTime)
assert.True(t, option.IsNone(result), "initial state should fail Check with maxFailures=0")
// Add one error
state = state.AddError(baseTime)
// Should still fail
result = state.Check(baseTime)
assert.True(t, option.IsNone(result), "should fail Check after error with maxFailures=0")
})
t.Run("success purges history even with failures in time window", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors within time window
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
// Add success (purges all history)
state = state.AddSuccess(baseTime.Add(20 * time.Second))
// Add more errors
state = state.AddError(baseTime.Add(30 * time.Second))
state = state.AddError(baseTime.Add(40 * time.Second))
// Should still pass (only 2 errors after purge)
result := state.Check(baseTime.Add(40 * time.Second))
assert.True(t, option.IsSome(result), "success should purge all history")
// Add one more to reach threshold
state = state.AddError(baseTime.Add(50 * time.Second))
// Should fail at threshold
result = state.Check(baseTime.Add(50 * time.Second))
assert.True(t, option.IsNone(result), "should fail after reaching threshold")
})
t.Run("multiple successes keep history empty", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(2)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add error
state = state.AddError(baseTime)
// Multiple successes
state = state.AddSuccess(baseTime.Add(10 * time.Second))
state = state.AddSuccess(baseTime.Add(20 * time.Second))
state = state.AddSuccess(baseTime.Add(30 * time.Second))
// Should still pass
result := state.Check(baseTime.Add(30 * time.Second))
assert.True(t, option.IsSome(result), "multiple AddSuccess should keep history empty")
// Add errors to reach threshold
state = state.AddError(baseTime.Add(40 * time.Second))
state = state.AddError(baseTime.Add(50 * time.Second))
// Should fail
result = state.Check(baseTime.Add(50 * time.Second))
assert.True(t, option.IsNone(result), "should fail after reaching threshold")
})
t.Run("state is immutable - original unchanged after AddError", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(2)
originalState := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Create new state by adding error
newState := originalState.AddError(baseTime)
// Original should still pass check
result := originalState.Check(baseTime)
assert.True(t, option.IsSome(result), "original state should be unchanged")
// New state should reach threshold after another error
newState = newState.AddError(baseTime.Add(10 * time.Second))
result = newState.Check(baseTime.Add(10 * time.Second))
assert.True(t, option.IsNone(result), "new state should fail after reaching threshold")
// Original should still pass
result = originalState.Check(baseTime)
assert.True(t, option.IsSome(result), "original state should still be unchanged")
})
t.Run("state is immutable - original unchanged after Empty", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(2)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors to original
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
stateWithErrors := state
// Create new state by calling Empty
emptyState := stateWithErrors.Empty()
// Original with errors should fail check
result := stateWithErrors.Check(baseTime.Add(10 * time.Second))
assert.True(t, option.IsNone(result), "state with errors should fail after reaching threshold")
// Empty state should pass
result = emptyState.Check(baseTime.Add(10 * time.Second))
assert.True(t, option.IsSome(result), "empty state should pass Check")
})
t.Run("exact time window boundary behavior", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add error at baseTime
state = state.AddError(baseTime)
// Add error exactly at time window boundary
state = state.AddError(baseTime.Add(1 * time.Minute))
// The first error should be removed (it's now outside the window)
// Only 1 error should remain
result := state.Check(baseTime.Add(1 * time.Minute))
assert.True(t, option.IsSome(result), "error at exact window boundary should remove older errors")
})
t.Run("multiple errors at same timestamp", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add multiple errors at same time
state = state.AddError(baseTime)
state = state.AddError(baseTime)
state = state.AddError(baseTime)
// Should fail check (3 errors at same time)
result := state.Check(baseTime)
assert.True(t, option.IsNone(result), "multiple errors at same timestamp should count separately")
})
t.Run("errors added out of chronological order are sorted", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(4)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors out of order
state = state.AddError(baseTime.Add(30 * time.Second))
state = state.AddError(baseTime.Add(5 * time.Second))
state = state.AddError(baseTime.Add(50 * time.Second))
// Add error that should trigger pruning
state = state.AddError(baseTime.Add(70 * time.Second))
// The error at 5s should be removed (> 1 minute from 70s: 70-5=65 > 60)
// Should have 3 errors remaining (30s, 50s, 70s)
result := state.Check(baseTime.Add(70 * time.Second))
assert.True(t, option.IsSome(result), "errors should be sorted and pruned correctly")
})
t.Run("large time window with many failures", func(t *testing.T) {
timeWindow := 24 * time.Hour
maxFailures := uint(100)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add many failures within the window
for i := 0; i < 99; i++ {
state = state.AddError(baseTime.Add(time.Duration(i) * time.Minute))
}
// Should still pass (99 < 100)
result := state.Check(baseTime.Add(99 * time.Minute))
assert.True(t, option.IsSome(result), "should pass with 99 failures when threshold is 100")
// Add one more to reach threshold
state = state.AddError(baseTime.Add(100 * time.Minute))
// Should fail
result = state.Check(baseTime.Add(100 * time.Minute))
assert.True(t, option.IsNone(result), "should fail at threshold with large window")
})
t.Run("very short time window", func(t *testing.T) {
timeWindow := 100 * time.Millisecond
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors within short window
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(50 * time.Millisecond))
state = state.AddError(baseTime.Add(90 * time.Millisecond))
// Should fail (3 errors within 100ms)
result := state.Check(baseTime.Add(90 * time.Millisecond))
assert.True(t, option.IsNone(result), "should fail with errors in short time window")
// Add error after window expires
state = state.AddError(baseTime.Add(200 * time.Millisecond))
// Should pass (old errors removed, only 1 in window)
result = state.Check(baseTime.Add(200 * time.Millisecond))
assert.True(t, option.IsSome(result), "should pass after short window expires")
})
t.Run("success prevents circuit from opening", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors close to threshold
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
// Add success before reaching threshold
state = state.AddSuccess(baseTime.Add(20 * time.Second))
// Add more errors
state = state.AddError(baseTime.Add(30 * time.Second))
state = state.AddError(baseTime.Add(40 * time.Second))
// Should still pass (only 2 errors after success purge)
result := state.Check(baseTime.Add(40 * time.Second))
assert.True(t, option.IsSome(result), "circuit should stay closed after success purge")
})
t.Run("Empty can be called multiple times", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(2)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
state = state.AddError(baseTime.Add(20 * time.Second))
// Reset multiple times
state = state.Empty()
state = state.Empty()
state = state.Empty()
// Should still pass
result := state.Check(baseTime.Add(30 * time.Second))
assert.True(t, option.IsSome(result), "state should pass Check after multiple Empty calls")
})
t.Run("gradual failure accumulation within window", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(5)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add failures gradually
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(15 * time.Second))
state = state.AddError(baseTime.Add(30 * time.Second))
state = state.AddError(baseTime.Add(45 * time.Second))
// Should still pass (4 < 5)
result := state.Check(baseTime.Add(45 * time.Second))
assert.True(t, option.IsSome(result), "should pass before threshold")
// Add one more within window
state = state.AddError(baseTime.Add(55 * time.Second))
// Should fail (5 >= 5)
result = state.Check(baseTime.Add(55 * time.Second))
assert.True(t, option.IsNone(result), "should fail at threshold")
})
}

335
v2/circuitbreaker/error.go Normal file
View File

@@ -0,0 +1,335 @@
// Package circuitbreaker provides error types and utilities for circuit breaker implementations.
package circuitbreaker
import (
"crypto/x509"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"syscall"
"time"
E "github.com/IBM/fp-go/v2/errors"
FH "github.com/IBM/fp-go/v2/http"
"github.com/IBM/fp-go/v2/option"
)
// CircuitBreakerError represents an error that occurs when a circuit breaker is in the open state.
//
// When a circuit breaker opens due to too many failures, it prevents further operations
// from executing until a reset time is reached. This error type communicates that state
// and provides information about when the circuit breaker will attempt to close again.
//
// Fields:
// - Name: The name identifying this circuit breaker instance
// - ResetAt: The time at which the circuit breaker will transition from open to half-open state
//
// Thread Safety: This type is immutable and safe for concurrent use.
type CircuitBreakerError struct {
Name string
ResetAt time.Time
}
// Error implements the error interface for CircuitBreakerError.
//
// Returns a formatted error message indicating that the circuit breaker is open
// and when it will attempt to close.
//
// Returns:
// - A string describing the circuit breaker state and reset time
//
// Thread Safety: This method is safe for concurrent use as it only reads immutable fields.
//
// Example:
//
// err := &CircuitBreakerError{Name: "API", ResetAt: time.Now().Add(30 * time.Second)}
// fmt.Println(err.Error())
// // Output: circuit breaker is open [API], will close at 2026-01-09 12:20:47.123 +0100 CET
func (e *CircuitBreakerError) Error() string {
return fmt.Sprintf("circuit breaker is open [%s], will close at %s", e.Name, e.ResetAt)
}
// MakeCircuitBreakerErrorWithName creates a circuit breaker error constructor with a custom name.
//
// This function returns a constructor that creates CircuitBreakerError instances with a specific
// circuit breaker name. This is useful when you have multiple circuit breakers in your system
// and want to identify which one is open in error messages.
//
// Parameters:
// - name: The name to identify this circuit breaker in error messages
//
// Returns:
// - A function that takes a reset time and returns a CircuitBreakerError with the specified name
//
// Thread Safety: The returned function is safe for concurrent use as it creates new error
// instances on each call.
//
// Example:
//
// makeDBError := MakeCircuitBreakerErrorWithName("Database Circuit Breaker")
// err := makeDBError(time.Now().Add(30 * time.Second))
// fmt.Println(err.Error())
// // Output: circuit breaker is open [Database Circuit Breaker], will close at 2026-01-09 12:20:47.123 +0100 CET
func MakeCircuitBreakerErrorWithName(name string) func(time.Time) error {
return func(resetTime time.Time) error {
return &CircuitBreakerError{Name: name, ResetAt: resetTime}
}
}
// MakeCircuitBreakerError creates a new CircuitBreakerError with the specified reset time.
//
// This constructor function creates a circuit breaker error that indicates when the
// circuit breaker will transition from the open state to the half-open state, allowing
// test requests to determine if the underlying service has recovered.
//
// Parameters:
// - resetTime: The time at which the circuit breaker will attempt to close
//
// Returns:
// - An error representing the circuit breaker open state
//
// Thread Safety: This function is safe for concurrent use as it creates new error
// instances on each call.
//
// Example:
//
// resetTime := time.Now().Add(30 * time.Second)
// err := MakeCircuitBreakerError(resetTime)
// if cbErr, ok := err.(*CircuitBreakerError); ok {
// fmt.Printf("Circuit breaker will reset at: %s\n", cbErr.ResetAt)
// }
var MakeCircuitBreakerError = MakeCircuitBreakerErrorWithName("Generic Circuit Breaker")
// AnyError converts an error to an Option, wrapping non-nil errors in Some and nil errors in None.
//
// This variable provides a functional way to handle errors by converting them to Option types.
// It's particularly useful in functional programming contexts where you want to treat errors
// as optional values rather than using traditional error handling patterns.
//
// Behavior:
// - If the error is non-nil, returns Some(error)
// - If the error is nil, returns None
//
// Thread Safety: This function is pure and safe for concurrent use.
//
// Example:
//
// err := errors.New("something went wrong")
// optErr := AnyError(err) // Some(error)
//
// var noErr error = nil
// optNoErr := AnyError(noErr) // None
//
// // Using in functional pipelines
// result := F.Pipe2(
// someOperation(),
// AnyError,
// O.Map(func(e error) string { return e.Error() }),
// )
var AnyError = option.FromPredicate(E.IsNonNil)
// shouldOpenCircuit determines if an error should cause a circuit breaker to open.
//
// This function checks if an error represents an infrastructure or server problem
// that indicates the service is unhealthy and should trigger circuit breaker protection.
// It examines both the error type and, for HTTP errors, the status code.
//
// Errors that should open the circuit include:
// - HTTP 5xx server errors (500-599) indicating server-side problems
// - Network errors (connection refused, connection reset, timeouts)
// - DNS resolution errors
// - TLS/certificate errors
// - Other infrastructure-related errors
//
// Errors that should NOT open the circuit include:
// - HTTP 4xx client errors (bad request, unauthorized, not found, etc.)
// - Application-level validation errors
// - Business logic errors
//
// The function unwraps error chains to find the root cause, making it compatible
// with wrapped errors created by fmt.Errorf with %w or errors.Join.
//
// Parameters:
// - err: The error to evaluate (may be nil)
//
// Returns:
// - true if the error should cause the circuit to open, false otherwise
//
// Thread Safety: This function is pure and safe for concurrent use. It does not
// modify any state.
//
// Example:
//
// // HTTP 500 error - should open circuit
// httpErr := &FH.HttpError{...} // status 500
// if shouldOpenCircuit(httpErr) {
// // Open circuit breaker
// }
//
// // HTTP 404 error - should NOT open circuit (client error)
// notFoundErr := &FH.HttpError{...} // status 404
// if !shouldOpenCircuit(notFoundErr) {
// // Don't open circuit, this is a client error
// }
//
// // Network timeout - should open circuit
// timeoutErr := &net.OpError{Op: "dial", Err: syscall.ETIMEDOUT}
// if shouldOpenCircuit(timeoutErr) {
// // Open circuit breaker
// }
func shouldOpenCircuit(err error) bool {
if err == nil {
return false
}
// Check for HTTP errors with server status codes (5xx)
var httpErr *FH.HttpError
if errors.As(err, &httpErr) {
statusCode := httpErr.StatusCode()
// Only 5xx errors should open the circuit
// 4xx errors are client errors and shouldn't affect circuit state
return statusCode >= http.StatusInternalServerError && statusCode < 600
}
// Check for network operation errors
var opErr *net.OpError
if errors.As(err, &opErr) {
// Network timeouts should open the circuit
if opErr.Timeout() {
return true
}
// Check the underlying error
if opErr.Err != nil {
return isInfrastructureError(opErr.Err)
}
return true
}
// Check for DNS errors
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return true
}
// Check for URL errors (often wrap network errors)
var urlErr *url.Error
if errors.As(err, &urlErr) {
if urlErr.Timeout() {
return true
}
// Recursively check the wrapped error
return shouldOpenCircuit(urlErr.Err)
}
// Check for specific syscall errors that indicate infrastructure problems
return isInfrastructureError(err) || isTLSError(err)
}
// isInfrastructureError checks if an error is a low-level infrastructure error
// that should cause the circuit to open.
//
// This function examines syscall errors to identify network and system-level failures
// that indicate the service is unavailable or unreachable.
//
// Infrastructure errors include:
// - ECONNREFUSED: Connection refused (service not listening)
// - ECONNRESET: Connection reset by peer (service crashed or network issue)
// - ECONNABORTED: Connection aborted (network issue)
// - ENETUNREACH: Network unreachable (routing problem)
// - EHOSTUNREACH: Host unreachable (host down or network issue)
// - EPIPE: Broken pipe (connection closed unexpectedly)
// - ETIMEDOUT: Operation timed out (service not responding)
//
// Parameters:
// - err: The error to check
//
// Returns:
// - true if the error is an infrastructure error, false otherwise
//
// Thread Safety: This function is pure and safe for concurrent use.
func isInfrastructureError(err error) bool {
var syscallErr *syscall.Errno
if errors.As(err, &syscallErr) {
switch *syscallErr {
case syscall.ECONNREFUSED,
syscall.ECONNRESET,
syscall.ECONNABORTED,
syscall.ENETUNREACH,
syscall.EHOSTUNREACH,
syscall.EPIPE,
syscall.ETIMEDOUT:
return true
}
}
return false
}
// isTLSError checks if an error is a TLS/certificate error that should cause the circuit to open.
//
// TLS errors typically indicate infrastructure or configuration problems that prevent
// secure communication with the service. These errors suggest the service is not properly
// configured or accessible.
//
// TLS errors include:
// - Certificate verification failures (invalid, expired, or malformed certificates)
// - Unknown certificate authority errors (untrusted CA)
//
// Parameters:
// - err: The error to check
//
// Returns:
// - true if the error is a TLS/certificate error, false otherwise
//
// Thread Safety: This function is pure and safe for concurrent use.
func isTLSError(err error) bool {
// Certificate verification failed
var certErr *x509.CertificateInvalidError
if errors.As(err, &certErr) {
return true
}
// Unknown authority
var unknownAuthErr *x509.UnknownAuthorityError
if errors.As(err, &unknownAuthErr) {
return true
}
return false
}
// InfrastructureError is a predicate that converts errors to Options based on whether
// they should trigger circuit breaker opening.
//
// This variable provides a functional way to filter errors that represent infrastructure
// failures (network issues, server errors, timeouts, etc.) from application-level errors
// (validation errors, business logic errors, client errors).
//
// Behavior:
// - Returns Some(error) if the error should open the circuit (infrastructure failure)
// - Returns None if the error should not open the circuit (application error)
//
// Thread Safety: This function is pure and safe for concurrent use.
//
// Use this in circuit breaker configurations to determine which errors should count
// toward the failure threshold.
//
// Example:
//
// // In a circuit breaker configuration
// breaker := MakeCircuitBreaker(
// ...,
// checkError: InfrastructureError, // Only infrastructure errors open the circuit
// ...,
// )
//
// // HTTP 500 error - returns Some(error)
// result := InfrastructureError(&FH.HttpError{...}) // Some(error)
//
// // HTTP 404 error - returns None
// result := InfrastructureError(&FH.HttpError{...}) // None
var InfrastructureError = option.FromPredicate(shouldOpenCircuit)

View File

@@ -0,0 +1,503 @@
package circuitbreaker
import (
"crypto/x509"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"testing"
"time"
FH "github.com/IBM/fp-go/v2/http"
"github.com/IBM/fp-go/v2/option"
"github.com/stretchr/testify/assert"
)
// TestCircuitBreakerError tests the CircuitBreakerError type
func TestCircuitBreakerError(t *testing.T) {
t.Run("Error returns formatted message with reset time", func(t *testing.T) {
resetTime := time.Date(2026, 1, 9, 12, 30, 0, 0, time.UTC)
err := &CircuitBreakerError{ResetAt: resetTime}
result := err.Error()
assert.Contains(t, result, "circuit breaker is open")
assert.Contains(t, result, "will close at")
assert.Contains(t, result, resetTime.String())
})
t.Run("Error message includes full timestamp", func(t *testing.T) {
resetTime := time.Now().Add(30 * time.Second)
err := &CircuitBreakerError{ResetAt: resetTime}
result := err.Error()
assert.NotEmpty(t, result)
assert.Contains(t, result, "circuit breaker is open")
})
}
// TestMakeCircuitBreakerError tests the constructor function
func TestMakeCircuitBreakerError(t *testing.T) {
t.Run("creates CircuitBreakerError with correct reset time", func(t *testing.T) {
resetTime := time.Date(2026, 1, 9, 13, 0, 0, 0, time.UTC)
err := MakeCircuitBreakerError(resetTime)
assert.NotNil(t, err)
cbErr, ok := err.(*CircuitBreakerError)
assert.True(t, ok, "should return *CircuitBreakerError type")
assert.Equal(t, resetTime, cbErr.ResetAt)
})
t.Run("returns error interface", func(t *testing.T) {
resetTime := time.Now().Add(1 * time.Minute)
err := MakeCircuitBreakerError(resetTime)
// Should be assignable to error interface
var _ error = err
assert.NotNil(t, err)
})
t.Run("created error can be type asserted", func(t *testing.T) {
resetTime := time.Now().Add(45 * time.Second)
err := MakeCircuitBreakerError(resetTime)
cbErr, ok := err.(*CircuitBreakerError)
assert.True(t, ok)
assert.Equal(t, resetTime, cbErr.ResetAt)
})
}
// TestAnyError tests the AnyError function
func TestAnyError(t *testing.T) {
t.Run("returns Some for non-nil error", func(t *testing.T) {
err := errors.New("test error")
result := AnyError(err)
assert.True(t, option.IsSome(result), "should return Some for non-nil error")
value := option.GetOrElse(func() error { return nil })(result)
assert.Equal(t, err, value)
})
t.Run("returns None for nil error", func(t *testing.T) {
var err error = nil
result := AnyError(err)
assert.True(t, option.IsNone(result), "should return None for nil error")
})
t.Run("works with different error types", func(t *testing.T) {
err1 := fmt.Errorf("wrapped: %w", errors.New("inner"))
err2 := &CircuitBreakerError{ResetAt: time.Now()}
result1 := AnyError(err1)
result2 := AnyError(err2)
assert.True(t, option.IsSome(result1))
assert.True(t, option.IsSome(result2))
})
}
// TestShouldOpenCircuit tests the shouldOpenCircuit function
func TestShouldOpenCircuit(t *testing.T) {
t.Run("returns false for nil error", func(t *testing.T) {
result := shouldOpenCircuit(nil)
assert.False(t, result)
})
t.Run("HTTP 5xx errors should open circuit", func(t *testing.T) {
testCases := []struct {
name string
statusCode int
expected bool
}{
{"500 Internal Server Error", 500, true},
{"501 Not Implemented", 501, true},
{"502 Bad Gateway", 502, true},
{"503 Service Unavailable", 503, true},
{"504 Gateway Timeout", 504, true},
{"599 Custom Server Error", 599, true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: tc.statusCode,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
result := shouldOpenCircuit(httpErr)
assert.Equal(t, tc.expected, result)
})
}
})
t.Run("HTTP 4xx errors should NOT open circuit", func(t *testing.T) {
testCases := []struct {
name string
statusCode int
expected bool
}{
{"400 Bad Request", 400, false},
{"401 Unauthorized", 401, false},
{"403 Forbidden", 403, false},
{"404 Not Found", 404, false},
{"422 Unprocessable Entity", 422, false},
{"429 Too Many Requests", 429, false},
{"499 Custom Client Error", 499, false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: tc.statusCode,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
result := shouldOpenCircuit(httpErr)
assert.Equal(t, tc.expected, result)
})
}
})
t.Run("HTTP 2xx and 3xx should NOT open circuit", func(t *testing.T) {
testCases := []int{200, 201, 204, 301, 302, 304}
for _, statusCode := range testCases {
t.Run(fmt.Sprintf("Status %d", statusCode), func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: statusCode,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
result := shouldOpenCircuit(httpErr)
assert.False(t, result)
})
}
})
t.Run("network timeout errors should open circuit", func(t *testing.T) {
opErr := &net.OpError{
Op: "dial",
Err: &timeoutError{},
}
result := shouldOpenCircuit(opErr)
assert.True(t, result)
})
t.Run("DNS errors should open circuit", func(t *testing.T) {
dnsErr := &net.DNSError{
Err: "no such host",
Name: "example.com",
}
result := shouldOpenCircuit(dnsErr)
assert.True(t, result)
})
t.Run("URL timeout errors should open circuit", func(t *testing.T) {
urlErr := &url.Error{
Op: "Get",
URL: "http://example.com",
Err: &timeoutError{},
}
result := shouldOpenCircuit(urlErr)
assert.True(t, result)
})
t.Run("URL errors with nested network timeout should open circuit", func(t *testing.T) {
urlErr := &url.Error{
Op: "Get",
URL: "http://example.com",
Err: &net.OpError{
Op: "dial",
Err: &timeoutError{},
},
}
result := shouldOpenCircuit(urlErr)
assert.True(t, result)
})
t.Run("OpError with nil Err should open circuit", func(t *testing.T) {
opErr := &net.OpError{
Op: "dial",
Err: nil,
}
result := shouldOpenCircuit(opErr)
assert.True(t, result)
})
t.Run("wrapped HTTP 5xx error should open circuit", func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: 503,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
wrappedErr := fmt.Errorf("service error: %w", httpErr)
result := shouldOpenCircuit(wrappedErr)
assert.True(t, result)
})
t.Run("wrapped HTTP 4xx error should NOT open circuit", func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: 404,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
wrappedErr := fmt.Errorf("not found: %w", httpErr)
result := shouldOpenCircuit(wrappedErr)
assert.False(t, result)
})
t.Run("generic application error should NOT open circuit", func(t *testing.T) {
err := errors.New("validation failed")
result := shouldOpenCircuit(err)
assert.False(t, result)
})
}
// TestIsInfrastructureError tests infrastructure error detection through shouldOpenCircuit
func TestIsInfrastructureError(t *testing.T) {
t.Run("network timeout is infrastructure error", func(t *testing.T) {
opErr := &net.OpError{Op: "dial", Err: &timeoutError{}}
result := shouldOpenCircuit(opErr)
assert.True(t, result)
})
t.Run("OpError with nil Err is infrastructure error", func(t *testing.T) {
opErr := &net.OpError{Op: "dial", Err: nil}
result := shouldOpenCircuit(opErr)
assert.True(t, result)
})
t.Run("generic error returns false", func(t *testing.T) {
err := errors.New("generic error")
result := shouldOpenCircuit(err)
assert.False(t, result)
})
t.Run("wrapped network timeout is detected", func(t *testing.T) {
opErr := &net.OpError{Op: "dial", Err: &timeoutError{}}
wrappedErr := fmt.Errorf("connection failed: %w", opErr)
result := shouldOpenCircuit(wrappedErr)
assert.True(t, result)
})
}
// TestIsTLSError tests the isTLSError function
func TestIsTLSError(t *testing.T) {
t.Run("certificate invalid error is TLS error", func(t *testing.T) {
certErr := &x509.CertificateInvalidError{
Reason: x509.Expired,
}
result := isTLSError(certErr)
assert.True(t, result)
})
t.Run("unknown authority error is TLS error", func(t *testing.T) {
authErr := &x509.UnknownAuthorityError{}
result := isTLSError(authErr)
assert.True(t, result)
})
t.Run("generic error is not TLS error", func(t *testing.T) {
err := errors.New("generic error")
result := isTLSError(err)
assert.False(t, result)
})
t.Run("wrapped certificate error is detected", func(t *testing.T) {
certErr := &x509.CertificateInvalidError{
Reason: x509.Expired,
}
wrappedErr := fmt.Errorf("TLS handshake failed: %w", certErr)
result := isTLSError(wrappedErr)
assert.True(t, result)
})
t.Run("wrapped unknown authority error is detected", func(t *testing.T) {
authErr := &x509.UnknownAuthorityError{}
wrappedErr := fmt.Errorf("certificate verification failed: %w", authErr)
result := isTLSError(wrappedErr)
assert.True(t, result)
})
}
// TestInfrastructureError tests the InfrastructureError variable
func TestInfrastructureError(t *testing.T) {
t.Run("returns Some for infrastructure errors", func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: 503,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
result := InfrastructureError(httpErr)
assert.True(t, option.IsSome(result))
})
t.Run("returns None for non-infrastructure errors", func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: 404,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
result := InfrastructureError(httpErr)
assert.True(t, option.IsNone(result))
})
t.Run("returns None for nil error", func(t *testing.T) {
result := InfrastructureError(nil)
assert.True(t, option.IsNone(result))
})
t.Run("returns Some for network timeout", func(t *testing.T) {
opErr := &net.OpError{
Op: "dial",
Err: &timeoutError{},
}
result := InfrastructureError(opErr)
assert.True(t, option.IsSome(result))
})
}
// TestComplexErrorScenarios tests complex real-world error scenarios
func TestComplexErrorScenarios(t *testing.T) {
t.Run("deeply nested URL error with HTTP 5xx", func(t *testing.T) {
testURL, _ := url.Parse("http://api.example.com")
resp := &http.Response{
StatusCode: 502,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
urlErr := &url.Error{
Op: "Get",
URL: "http://api.example.com",
Err: httpErr,
}
wrappedErr := fmt.Errorf("API call failed: %w", urlErr)
result := shouldOpenCircuit(wrappedErr)
assert.True(t, result, "should detect HTTP 5xx through multiple layers")
})
t.Run("URL error with timeout nested in OpError", func(t *testing.T) {
opErr := &net.OpError{
Op: "dial",
Err: &timeoutError{},
}
urlErr := &url.Error{
Op: "Post",
URL: "http://api.example.com",
Err: opErr,
}
result := shouldOpenCircuit(urlErr)
assert.True(t, result, "should detect timeout through URL error")
})
t.Run("multiple wrapped errors with infrastructure error at core", func(t *testing.T) {
coreErr := &net.OpError{Op: "dial", Err: &timeoutError{}}
layer1 := fmt.Errorf("connection attempt failed: %w", coreErr)
layer2 := fmt.Errorf("retry exhausted: %w", layer1)
layer3 := fmt.Errorf("service unavailable: %w", layer2)
result := shouldOpenCircuit(layer3)
assert.True(t, result, "should unwrap to find infrastructure error")
})
t.Run("OpError with nil Err should open circuit", func(t *testing.T) {
opErr := &net.OpError{
Op: "dial",
Err: nil,
}
result := shouldOpenCircuit(opErr)
assert.True(t, result, "OpError with nil Err should be treated as infrastructure error")
})
t.Run("mixed error types - HTTP 4xx with network error", func(t *testing.T) {
// This tests that we correctly identify the error type
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: 400,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
result := shouldOpenCircuit(httpErr)
assert.False(t, result, "HTTP 4xx should not open circuit even if wrapped")
})
}
// Helper type for testing timeout errors
type timeoutError struct{}
func (e *timeoutError) Error() string { return "timeout" }
func (e *timeoutError) Timeout() bool { return true }
func (e *timeoutError) Temporary() bool { return true }

View File

@@ -0,0 +1,208 @@
// Package circuitbreaker provides metrics collection for circuit breaker state transitions and events.
package circuitbreaker
import (
"log"
"time"
"github.com/IBM/fp-go/v2/function"
)
type (
// Metrics defines the interface for collecting circuit breaker metrics and events.
// Implementations can use this interface to track circuit breaker behavior for
// monitoring, alerting, and debugging purposes.
//
// All methods accept a time.Time parameter representing when the event occurred,
// and return an IO[Void] operation that performs the metric recording when executed.
//
// Thread Safety: Implementations must be thread-safe as circuit breakers may be
// accessed concurrently from multiple goroutines.
//
// Example Usage:
//
// logger := log.New(os.Stdout, "[CircuitBreaker] ", log.LstdFlags)
// metrics := MakeMetricsFromLogger("API-Service", logger)
//
// // In circuit breaker implementation
// io.Run(metrics.Accept(time.Now())) // Record accepted request
// io.Run(metrics.Reject(time.Now())) // Record rejected request
// io.Run(metrics.Open(time.Now())) // Record circuit opening
// io.Run(metrics.Close(time.Now())) // Record circuit closing
// io.Run(metrics.Canary(time.Now())) // Record canary request
Metrics interface {
// Accept records that a request was accepted and allowed through the circuit breaker.
// This is called when the circuit is closed or in half-open state (canary request).
//
// Parameters:
// - time.Time: The timestamp when the request was accepted
//
// Returns:
// - IO[Void]: An IO operation that records the acceptance when executed
//
// Thread Safety: Must be safe to call concurrently.
Accept(time.Time) IO[Void]
// Reject records that a request was rejected because the circuit breaker is open.
// This is called when a request is blocked due to the circuit being in open state
// and the reset time has not been reached.
//
// Parameters:
// - time.Time: The timestamp when the request was rejected
//
// Returns:
// - IO[Void]: An IO operation that records the rejection when executed
//
// Thread Safety: Must be safe to call concurrently.
Reject(time.Time) IO[Void]
// Open records that the circuit breaker transitioned to the open state.
// This is called when the failure threshold is exceeded and the circuit opens
// to prevent further requests from reaching the failing service.
//
// Parameters:
// - time.Time: The timestamp when the circuit opened
//
// Returns:
// - IO[Void]: An IO operation that records the state transition when executed
//
// Thread Safety: Must be safe to call concurrently.
Open(time.Time) IO[Void]
// Close records that the circuit breaker transitioned to the closed state.
// This is called when:
// - A canary request succeeds in half-open state
// - The circuit is manually reset
// - The circuit breaker is initialized
//
// Parameters:
// - time.Time: The timestamp when the circuit closed
//
// Returns:
// - IO[Void]: An IO operation that records the state transition when executed
//
// Thread Safety: Must be safe to call concurrently.
Close(time.Time) IO[Void]
// Canary records that a canary (test) request is being attempted.
// This is called when the circuit is in half-open state and a single test request
// is allowed through to check if the service has recovered.
//
// Parameters:
// - time.Time: The timestamp when the canary request was initiated
//
// Returns:
// - IO[Void]: An IO operation that records the canary attempt when executed
//
// Thread Safety: Must be safe to call concurrently.
Canary(time.Time) IO[Void]
}
// loggingMetrics is a simple implementation of the Metrics interface that logs
// circuit breaker events using Go's standard log.Logger.
//
// This implementation is thread-safe as log.Logger is safe for concurrent use.
//
// Fields:
// - name: A human-readable name identifying the circuit breaker instance
// - logger: The log.Logger instance used for writing log messages
loggingMetrics struct {
name string
logger *log.Logger
}
)
// doLog is a helper method that creates an IO operation for logging a circuit breaker event.
// It formats the log message with the event prefix, circuit breaker name, and timestamp.
//
// Parameters:
// - prefix: The event type (e.g., "Accept", "Reject", "Open", "Close", "Canary")
// - ct: The timestamp when the event occurred
//
// Returns:
// - IO[Void]: An IO operation that logs the event when executed
//
// Thread Safety: Safe for concurrent use as log.Logger is thread-safe.
//
// Log Format: "<prefix>: <name>, <timestamp>"
// Example: "Open: API-Service, 2026-01-09 15:30:45.123 +0100 CET"
func (m *loggingMetrics) doLog(prefix string, ct time.Time) IO[Void] {
return func() Void {
m.logger.Printf("%s: %s, %s\n", prefix, m.name, ct)
return function.VOID
}
}
// Accept implements the Metrics interface for loggingMetrics.
// Logs when a request is accepted through the circuit breaker.
//
// Thread Safety: Safe for concurrent use.
func (m *loggingMetrics) Accept(ct time.Time) IO[Void] {
return m.doLog("Accept", ct)
}
// Open implements the Metrics interface for loggingMetrics.
// Logs when the circuit breaker transitions to open state.
//
// Thread Safety: Safe for concurrent use.
func (m *loggingMetrics) Open(ct time.Time) IO[Void] {
return m.doLog("Open", ct)
}
// Close implements the Metrics interface for loggingMetrics.
// Logs when the circuit breaker transitions to closed state.
//
// Thread Safety: Safe for concurrent use.
func (m *loggingMetrics) Close(ct time.Time) IO[Void] {
return m.doLog("Close", ct)
}
// Reject implements the Metrics interface for loggingMetrics.
// Logs when a request is rejected because the circuit breaker is open.
//
// Thread Safety: Safe for concurrent use.
func (m *loggingMetrics) Reject(ct time.Time) IO[Void] {
return m.doLog("Reject", ct)
}
// Canary implements the Metrics interface for loggingMetrics.
// Logs when a canary (test) request is attempted in half-open state.
//
// Thread Safety: Safe for concurrent use.
func (m *loggingMetrics) Canary(ct time.Time) IO[Void] {
return m.doLog("Canary", ct)
}
// MakeMetricsFromLogger creates a Metrics implementation that logs circuit breaker events
// using the provided log.Logger.
//
// This is a simple metrics implementation suitable for development, debugging, and
// basic production monitoring. For more sophisticated metrics collection (e.g., Prometheus,
// StatsD), implement the Metrics interface with a custom type.
//
// Parameters:
// - name: A human-readable name identifying the circuit breaker instance.
// This name appears in all log messages to distinguish between multiple circuit breakers.
// - logger: The log.Logger instance to use for writing log messages.
// If nil, this will panic when metrics are recorded.
//
// Returns:
// - Metrics: A thread-safe Metrics implementation that logs events
//
// Thread Safety: The returned Metrics implementation is safe for concurrent use
// as log.Logger is thread-safe.
//
// Example:
//
// logger := log.New(os.Stdout, "[CB] ", log.LstdFlags)
// metrics := MakeMetricsFromLogger("UserService", logger)
//
// // Use with circuit breaker
// io.Run(metrics.Open(time.Now()))
// // Output: [CB] 2026/01/09 15:30:45 Open: UserService, 2026-01-09 15:30:45.123 +0100 CET
//
// io.Run(metrics.Reject(time.Now()))
// // Output: [CB] 2026/01/09 15:30:46 Reject: UserService, 2026-01-09 15:30:46.456 +0100 CET
func MakeMetricsFromLogger(name string, logger *log.Logger) Metrics {
return &loggingMetrics{name: name, logger: logger}
}

View File

@@ -0,0 +1,506 @@
package circuitbreaker
import (
"bytes"
"log"
"strings"
"sync"
"testing"
"time"
"github.com/IBM/fp-go/v2/io"
"github.com/stretchr/testify/assert"
)
// TestMakeMetricsFromLogger tests the MakeMetricsFromLogger constructor
func TestMakeMetricsFromLogger(t *testing.T) {
t.Run("creates valid Metrics implementation", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
assert.NotNil(t, metrics, "MakeMetricsFromLogger should return non-nil Metrics")
})
t.Run("returns loggingMetrics type", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
_, ok := metrics.(*loggingMetrics)
assert.True(t, ok, "should return *loggingMetrics type")
})
t.Run("stores name correctly", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
name := "MyCircuitBreaker"
metrics := MakeMetricsFromLogger(name, logger).(*loggingMetrics)
assert.Equal(t, name, metrics.name, "name should be stored correctly")
})
t.Run("stores logger correctly", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger).(*loggingMetrics)
assert.Equal(t, logger, metrics.logger, "logger should be stored correctly")
})
}
// TestLoggingMetricsAccept tests the Accept method
func TestLoggingMetricsAccept(t *testing.T) {
t.Run("logs accept event with correct format", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
io.Run(metrics.Accept(timestamp))
output := buf.String()
assert.Contains(t, output, "Accept:", "should contain Accept prefix")
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
})
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Accept(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
t.Run("logs multiple accept events", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
time1 := time.Date(2026, 1, 9, 15, 30, 0, 0, time.UTC)
time2 := time.Date(2026, 1, 9, 15, 31, 0, 0, time.UTC)
io.Run(metrics.Accept(time1))
io.Run(metrics.Accept(time2))
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
assert.Len(t, lines, 2, "should have 2 log lines")
assert.Contains(t, lines[0], time1.String())
assert.Contains(t, lines[1], time2.String())
})
}
// TestLoggingMetricsReject tests the Reject method
func TestLoggingMetricsReject(t *testing.T) {
t.Run("logs reject event with correct format", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
io.Run(metrics.Reject(timestamp))
output := buf.String()
assert.Contains(t, output, "Reject:", "should contain Reject prefix")
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
})
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Reject(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
}
// TestLoggingMetricsOpen tests the Open method
func TestLoggingMetricsOpen(t *testing.T) {
t.Run("logs open event with correct format", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
io.Run(metrics.Open(timestamp))
output := buf.String()
assert.Contains(t, output, "Open:", "should contain Open prefix")
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
})
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Open(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
}
// TestLoggingMetricsClose tests the Close method
func TestLoggingMetricsClose(t *testing.T) {
t.Run("logs close event with correct format", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
io.Run(metrics.Close(timestamp))
output := buf.String()
assert.Contains(t, output, "Close:", "should contain Close prefix")
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
})
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Close(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
}
// TestLoggingMetricsCanary tests the Canary method
func TestLoggingMetricsCanary(t *testing.T) {
t.Run("logs canary event with correct format", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
io.Run(metrics.Canary(timestamp))
output := buf.String()
assert.Contains(t, output, "Canary:", "should contain Canary prefix")
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
})
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Canary(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
}
// TestLoggingMetricsDoLog tests the doLog helper method
func TestLoggingMetricsDoLog(t *testing.T) {
t.Run("formats log message correctly", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := &loggingMetrics{name: "TestCircuit", logger: logger}
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
io.Run(metrics.doLog("CustomEvent", timestamp))
output := buf.String()
assert.Contains(t, output, "CustomEvent:", "should contain custom prefix")
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
})
t.Run("handles different prefixes", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := &loggingMetrics{name: "TestCircuit", logger: logger}
timestamp := time.Now()
prefixes := []string{"Accept", "Reject", "Open", "Close", "Canary", "Custom"}
for _, prefix := range prefixes {
buf.Reset()
io.Run(metrics.doLog(prefix, timestamp))
output := buf.String()
assert.Contains(t, output, prefix+":", "should contain prefix: "+prefix)
}
})
}
// TestMetricsIntegration tests integration scenarios
func TestMetricsIntegration(t *testing.T) {
t.Run("logs complete circuit breaker lifecycle", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("APICircuit", logger)
baseTime := time.Date(2026, 1, 9, 15, 30, 0, 0, time.UTC)
// Simulate circuit breaker lifecycle
io.Run(metrics.Accept(baseTime)) // Request accepted
io.Run(metrics.Accept(baseTime.Add(1 * time.Second))) // Another request
io.Run(metrics.Open(baseTime.Add(2 * time.Second))) // Circuit opens
io.Run(metrics.Reject(baseTime.Add(3 * time.Second))) // Request rejected
io.Run(metrics.Canary(baseTime.Add(30 * time.Second))) // Canary attempt
io.Run(metrics.Close(baseTime.Add(31 * time.Second))) // Circuit closes
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
assert.Len(t, lines, 6, "should have 6 log lines")
assert.Contains(t, lines[0], "Accept:")
assert.Contains(t, lines[1], "Accept:")
assert.Contains(t, lines[2], "Open:")
assert.Contains(t, lines[3], "Reject:")
assert.Contains(t, lines[4], "Canary:")
assert.Contains(t, lines[5], "Close:")
})
t.Run("distinguishes between multiple circuit breakers", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics1 := MakeMetricsFromLogger("Circuit1", logger)
metrics2 := MakeMetricsFromLogger("Circuit2", logger)
timestamp := time.Now()
io.Run(metrics1.Accept(timestamp))
io.Run(metrics2.Accept(timestamp))
output := buf.String()
assert.Contains(t, output, "Circuit1", "should contain first circuit name")
assert.Contains(t, output, "Circuit2", "should contain second circuit name")
})
}
// TestMetricsThreadSafety tests concurrent access to metrics
func TestMetricsThreadSafety(t *testing.T) {
t.Run("handles concurrent metric recording", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("ConcurrentCircuit", logger)
var wg sync.WaitGroup
numGoroutines := 100
wg.Add(numGoroutines)
// Launch multiple goroutines recording metrics concurrently
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
timestamp := time.Now()
io.Run(metrics.Accept(timestamp))
}(i)
}
wg.Wait()
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
assert.Len(t, lines, numGoroutines, "should have logged all events")
})
t.Run("handles concurrent different event types", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("ConcurrentCircuit", logger)
var wg sync.WaitGroup
numIterations := 20
wg.Add(numIterations * 5) // 5 event types
timestamp := time.Now()
for i := 0; i < numIterations; i++ {
go func() {
defer wg.Done()
io.Run(metrics.Accept(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Reject(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Open(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Close(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Canary(timestamp))
}()
}
wg.Wait()
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
assert.Len(t, lines, numIterations*5, "should have logged all events")
})
}
// TestMetricsEdgeCases tests edge cases and special scenarios
func TestMetricsEdgeCases(t *testing.T) {
t.Run("handles empty circuit breaker name", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("", logger)
timestamp := time.Now()
io.Run(metrics.Accept(timestamp))
output := buf.String()
assert.NotEmpty(t, output, "should still log even with empty name")
})
t.Run("handles very long circuit breaker name", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
longName := strings.Repeat("VeryLongCircuitBreakerName", 100)
metrics := MakeMetricsFromLogger(longName, logger)
timestamp := time.Now()
io.Run(metrics.Accept(timestamp))
output := buf.String()
assert.Contains(t, output, longName, "should handle long names")
})
t.Run("handles special characters in name", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
specialName := "Circuit-Breaker_123!@#$%^&*()"
metrics := MakeMetricsFromLogger(specialName, logger)
timestamp := time.Now()
io.Run(metrics.Accept(timestamp))
output := buf.String()
assert.Contains(t, output, specialName, "should handle special characters")
})
t.Run("handles zero time", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
zeroTime := time.Time{}
io.Run(metrics.Accept(zeroTime))
output := buf.String()
assert.NotEmpty(t, output, "should handle zero time")
assert.Contains(t, output, "Accept:")
})
t.Run("handles far future time", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
futureTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
io.Run(metrics.Accept(futureTime))
output := buf.String()
assert.NotEmpty(t, output, "should handle far future time")
assert.Contains(t, output, "9999")
})
}
// TestMetricsWithCustomLogger tests metrics with different logger configurations
func TestMetricsWithCustomLogger(t *testing.T) {
t.Run("works with logger with custom prefix", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "[CB] ", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
io.Run(metrics.Accept(timestamp))
output := buf.String()
assert.Contains(t, output, "[CB]", "should include custom prefix")
assert.Contains(t, output, "Accept:")
})
t.Run("works with logger with flags", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", log.Ldate|log.Ltime)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
io.Run(metrics.Accept(timestamp))
output := buf.String()
assert.NotEmpty(t, output, "should log with flags")
assert.Contains(t, output, "Accept:")
})
}
// TestMetricsIOOperations tests IO operation behavior
func TestMetricsIOOperations(t *testing.T) {
t.Run("IO operations are lazy", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
// Create IO operation but don't execute it
_ = metrics.Accept(timestamp)
// Buffer should be empty because IO wasn't executed
assert.Empty(t, buf.String(), "IO operation should be lazy")
})
t.Run("IO operations execute when run", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Accept(timestamp)
io.Run(ioOp)
assert.NotEmpty(t, buf.String(), "IO operation should execute when run")
})
t.Run("same IO operation can be executed multiple times", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Accept(timestamp)
io.Run(ioOp)
io.Run(ioOp)
io.Run(ioOp)
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
assert.Len(t, lines, 3, "should execute multiple times")
})
}

118
v2/circuitbreaker/types.go Normal file
View File

@@ -0,0 +1,118 @@
// Package circuitbreaker provides a functional implementation of the circuit breaker pattern.
// A circuit breaker prevents cascading failures by temporarily blocking requests to a failing service,
// allowing it time to recover before retrying.
//
// # Thread Safety
//
// All data structures in this package are immutable except for IORef[BreakerState].
// The IORef provides thread-safe mutable state through atomic operations.
//
// Immutable types (safe for concurrent use):
// - BreakerState (Either[openState, ClosedState])
// - openState
// - ClosedState implementations (closedStateWithErrorCount, closedStateWithHistory)
// - All function types and readers
//
// Mutable types (thread-safe through atomic operations):
// - IORef[BreakerState] - provides atomic read/write/modify operations
//
// ClosedState implementations must be thread-safe. The recommended approach is to
// return new copies for all operations (Empty, AddError, AddSuccess, Check), which
// provides automatic thread safety through immutability.
package circuitbreaker
import (
"time"
"github.com/IBM/fp-go/v2/either"
"github.com/IBM/fp-go/v2/endomorphism"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioref"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/ord"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/retry"
"github.com/IBM/fp-go/v2/state"
)
type (
// Ord is a type alias for ord.Ord, representing a total ordering on type A.
// Used for comparing values in a consistent way.
Ord[A any] = ord.Ord[A]
// Option is a type alias for option.Option, representing an optional value.
// It can be either Some(value) or None, used for safe handling of nullable values.
Option[A any] = option.Option[A]
// Endomorphism is a type alias for endomorphism.Endomorphism, representing a function from A to A.
// Used for transformations that preserve the type.
Endomorphism[A any] = endomorphism.Endomorphism[A]
// IO is a type alias for io.IO, representing a lazy computation that produces a value of type T.
// Used for side-effectful operations that are deferred until execution.
IO[T any] = io.IO[T]
// Pair is a type alias for pair.Pair, representing a tuple of two values.
// Used for grouping related values together.
Pair[L, R any] = pair.Pair[L, R]
// IORef is a type alias for ioref.IORef, representing a mutable reference to a value of type T.
// Used for managing mutable state in a functional way with IO operations.
IORef[T any] = ioref.IORef[T]
// State is a type alias for state.State, representing a stateful computation.
// It transforms a state of type T and produces a result of type R.
State[T, R any] = state.State[T, R]
// Either is a type alias for either.Either, representing a value that can be one of two types.
// Left[E] represents an error or alternative path, Right[A] represents the success path.
Either[E, A any] = either.Either[E, A]
// Predicate is a type alias for predicate.Predicate, representing a function that tests a value.
// Returns true if the value satisfies the predicate condition, false otherwise.
Predicate[A any] = predicate.Predicate[A]
// Reader is a type alias for reader.Reader, representing a computation that depends on an environment R
// and produces a value of type A. Used for dependency injection and configuration.
Reader[R, A any] = reader.Reader[R, A]
// openState represents the internal state when the circuit breaker is open.
// In the open state, requests are blocked to give the failing service time to recover.
// The circuit breaker will transition to a half-open state (canary request) after resetAt.
openState struct {
openedAt time.Time
// resetAt is the time when the circuit breaker should attempt a canary request
// to test if the service has recovered. Calculated based on the retry policy.
resetAt time.Time
// retryStatus tracks the current retry attempt information, including the number
// of retries and the delay between attempts. Used by the retry policy to calculate
// exponential backoff or other retry strategies.
retryStatus retry.RetryStatus
// canaryRequest indicates whether the circuit is in half-open state, allowing
// a single test request (canary) to check if the service has recovered.
// If true, one request is allowed through to test the service.
// If the canary succeeds, the circuit closes; if it fails, the circuit remains open
// with an extended reset time.
canaryRequest bool
}
// BreakerState represents the current state of the circuit breaker.
// It is an Either type where:
// - Left[openState] represents an open circuit (requests are blocked)
// - Right[ClosedState] represents a closed circuit (requests are allowed through)
//
// State Transitions:
// - Closed -> Open: When failure threshold is exceeded in ClosedState
// - Open -> Half-Open: When resetAt is reached (canaryRequest = true)
// - Half-Open -> Closed: When canary request succeeds
// - Half-Open -> Open: When canary request fails (with extended resetAt)
BreakerState = Either[openState, ClosedState]
Void = function.Void
)

View File

@@ -52,5 +52,7 @@ type (
// }
Consumer[A any] = func(A)
// Operator represents a function that transforms a Consumer[A] into a Consumer[B].
// This is useful for composing and adapting consumers to work with different types.
Operator[A, B any] = func(Consumer[A]) Consumer[B]
)

View File

@@ -21,7 +21,34 @@ import (
"github.com/IBM/fp-go/v2/result"
)
// withContext wraps an existing IOEither and performs a context check for cancellation before delegating
// WithContext wraps an IOResult and performs a context check for cancellation before executing.
// This ensures that if the context is already cancelled, the computation short-circuits immediately
// without executing the wrapped computation.
//
// This is useful for adding cancellation awareness to computations that might not check the context themselves.
//
// Type Parameters:
// - A: The type of the success value
//
// Parameters:
// - ctx: The context to check for cancellation
// - ma: The IOResult to wrap with context checking
//
// Returns:
// - An IOResult that checks for cancellation before executing
//
// Example:
//
// computation := func() Result[string] {
// // Long-running operation
// return result.Of("done")
// }
//
// ctx, cancel := context.WithCancel(context.Background())
// cancel() // Cancel immediately
//
// wrapped := WithContext(ctx, computation)
// result := wrapped() // Returns Left with context.Canceled error
func WithContext[A any](ctx context.Context, ma IOResult[A]) IOResult[A] {
return func() Result[A] {
if ctx.Err() != nil {

View File

@@ -6,6 +6,11 @@ import (
)
type (
// IOResult represents a synchronous computation that may fail with an error.
// It's an alias for ioresult.IOResult[T].
IOResult[T any] = ioresult.IOResult[T]
Result[T any] = result.Result[T]
// Result represents a computation that may fail with an error.
// It's an alias for result.Result[T].
Result[T any] = result.Result[T]
)

View File

@@ -4,6 +4,65 @@ import (
RIO "github.com/IBM/fp-go/v2/readerio"
)
// Bracket ensures that a resource is properly acquired, used, and released, even if an error occurs.
// This implements the bracket pattern for safe resource management with [ReaderIO].
//
// The bracket pattern guarantees that:
// - The acquire action is executed first to obtain the resource
// - The use function is called with the acquired resource
// - The release function is always called with the resource and result, regardless of success or failure
// - The final result from the use function is returned
//
// This is particularly useful for managing resources like file handles, database connections,
// or locks that must be cleaned up properly.
//
// Type Parameters:
// - A: The type of the acquired resource
// - B: The type of the result produced by the use function
// - ANY: The type returned by the release function (typically ignored)
//
// Parameters:
// - acquire: A ReaderIO that acquires the resource
// - use: A Kleisli arrow that uses the resource and produces a result
// - release: A function that releases the resource, receiving both the resource and the result
//
// Returns:
// - A ReaderIO[B] that safely manages the resource lifecycle
//
// Example:
//
// // Acquire a file handle
// acquireFile := func(ctx context.Context) IO[*os.File] {
// return func() *os.File {
// f, _ := os.Open("data.txt")
// return f
// }
// }
//
// // Use the file
// readFile := func(f *os.File) ReaderIO[string] {
// return func(ctx context.Context) IO[string] {
// return func() string {
// data, _ := io.ReadAll(f)
// return string(data)
// }
// }
// }
//
// // Release the file
// closeFile := func(f *os.File, result string) ReaderIO[any] {
// return func(ctx context.Context) IO[any] {
// return func() any {
// f.Close()
// return nil
// }
// }
// }
//
// // Safely read file with automatic cleanup
// safeRead := Bracket(acquireFile, readFile, closeFile)
// result := safeRead(context.Background())()
//
//go:inline
func Bracket[
A, B, ANY any](

View File

@@ -2,12 +2,64 @@ package readerio
import "github.com/IBM/fp-go/v2/io"
// ChainConsumer chains a consumer function into a ReaderIO computation, discarding the original value.
// This is useful for performing side effects (like logging or metrics) that consume a value
// but don't produce a meaningful result.
//
// The consumer is executed for its side effects, and the computation returns an empty struct.
//
// Type Parameters:
// - A: The type of value to consume
//
// Parameters:
// - c: A consumer function that performs side effects on the value
//
// Returns:
// - An Operator that chains the consumer and returns struct{}
//
// Example:
//
// logUser := func(u User) {
// log.Printf("Processing user: %s", u.Name)
// }
//
// pipeline := F.Pipe2(
// fetchUser(123),
// ChainConsumer(logUser),
// )
//
//go:inline
func ChainConsumer[A any](c Consumer[A]) Operator[A, struct{}] {
return ChainIOK(io.FromConsumerK(c))
func ChainConsumer[A any](c Consumer[A]) Operator[A, Void] {
return ChainIOK(io.FromConsumer(c))
}
// ChainFirstConsumer chains a consumer function into a ReaderIO computation, preserving the original value.
// This is useful for performing side effects (like logging or metrics) while passing the value through unchanged.
//
// The consumer is executed for its side effects, but the original value is returned.
//
// Type Parameters:
// - A: The type of value to consume and return
//
// Parameters:
// - c: A consumer function that performs side effects on the value
//
// Returns:
// - An Operator that chains the consumer and returns the original value
//
// Example:
//
// logUser := func(u User) {
// log.Printf("User: %s", u.Name)
// }
//
// pipeline := F.Pipe3(
// fetchUser(123),
// ChainFirstConsumer(logUser), // Logs but passes user through
// Map(func(u User) string { return u.Email }),
// )
//
//go:inline
func ChainFirstConsumer[A any](c Consumer[A]) Operator[A, A] {
return ChainFirstIOK(io.FromConsumerK(c))
return ChainFirstIOK(io.FromConsumer(c))
}

View File

@@ -7,11 +7,108 @@ import (
RIO "github.com/IBM/fp-go/v2/readerio"
)
// SequenceReader transforms a ReaderIO containing a Reader into a Reader containing a ReaderIO.
// This "flips" the nested structure, allowing you to provide the Reader's environment first,
// then get a ReaderIO that can be executed with a context.
//
// Type transformation:
//
// From: ReaderIO[Reader[R, A]]
// = func(context.Context) func() func(R) A
//
// To: Reader[R, ReaderIO[A]]
// = func(R) func(context.Context) func() A
//
// This is useful for point-free style programming where you want to partially apply
// the Reader's environment before dealing with the context.
//
// Type Parameters:
// - R: The environment type that the Reader depends on
// - A: The value type
//
// Parameters:
// - ma: A ReaderIO containing a Reader
//
// Returns:
// - A Reader that produces a ReaderIO when given an environment
//
// Example:
//
// type Config struct {
// Timeout int
// }
//
// // A computation that produces a Reader
// getMultiplier := func(ctx context.Context) IO[func(Config) int] {
// return func() func(Config) int {
// return func(cfg Config) int {
// return cfg.Timeout * 2
// }
// }
// }
//
// // Sequence it to apply Config first
// sequenced := SequenceReader[Config, int](getMultiplier)
// cfg := Config{Timeout: 30}
// result := sequenced(cfg)(context.Background())() // Returns 60
//
//go:inline
func SequenceReader[R, A any](ma ReaderIO[Reader[R, A]]) Reader[R, ReaderIO[A]] {
return RIO.SequenceReader(ma)
}
// TraverseReader applies a Reader-based transformation to a ReaderIO, introducing a new environment dependency.
//
// This function takes a Reader-based Kleisli arrow and returns a function that can transform
// a ReaderIO. The result allows you to provide the Reader's environment (R) first, which then
// produces a ReaderIO that depends on the context.
//
// Type transformation:
//
// From: ReaderIO[A]
// = func(context.Context) func() A
//
// With: reader.Kleisli[R, A, B]
// = func(A) func(R) B
//
// To: func(ReaderIO[A]) func(R) ReaderIO[B]
// = func(ReaderIO[A]) func(R) func(context.Context) func() B
//
// This enables transforming values within a ReaderIO using environment-dependent logic.
//
// Type Parameters:
// - R: The environment type that the Reader depends on
// - A: The input value type
// - B: The output value type
//
// Parameters:
// - f: A Reader-based Kleisli arrow that transforms A to B using environment R
//
// Returns:
// - A function that takes a ReaderIO[A] and returns a function from R to ReaderIO[B]
//
// Example:
//
// type Config struct {
// Multiplier int
// }
//
// // A Reader-based transformation
// multiply := func(x int) func(Config) int {
// return func(cfg Config) int {
// return x * cfg.Multiplier
// }
// }
//
// // Apply TraverseReader
// traversed := TraverseReader[Config, int, int](multiply)
// computation := Of(10)
// result := traversed(computation)
//
// // Provide Config to get final result
// cfg := Config{Multiplier: 5}
// finalResult := result(cfg)(context.Background())() // Returns 50
//
//go:inline
func TraverseReader[R, A, B any](
f reader.Kleisli[R, A, B],

View File

@@ -7,6 +7,40 @@ import (
"github.com/IBM/fp-go/v2/logging"
)
// SLogWithCallback creates a Kleisli arrow that logs a value with a custom logger and log level.
// The value is logged and then passed through unchanged, making this useful for debugging
// and monitoring values as they flow through a ReaderIO computation.
//
// Type Parameters:
// - A: The type of value to log and pass through
//
// Parameters:
// - logLevel: The slog.Level to use for logging (e.g., slog.LevelInfo, slog.LevelDebug)
// - cb: Callback function to retrieve the *slog.Logger from the context
// - message: A descriptive message to include in the log entry
//
// Returns:
// - A Kleisli arrow that logs the value and returns it unchanged
//
// Example:
//
// getMyLogger := func(ctx context.Context) *slog.Logger {
// if logger := ctx.Value("logger"); logger != nil {
// return logger.(*slog.Logger)
// }
// return slog.Default()
// }
//
// debugLog := SLogWithCallback[User](
// slog.LevelDebug,
// getMyLogger,
// "Processing user",
// )
//
// pipeline := F.Pipe2(
// fetchUser(123),
// Chain(debugLog),
// )
func SLogWithCallback[A any](
logLevel slog.Level,
cb func(context.Context) *slog.Logger,
@@ -23,6 +57,34 @@ func SLogWithCallback[A any](
}
}
// SLog creates a Kleisli arrow that logs a value at Info level and passes it through unchanged.
// This is a convenience wrapper around SLogWithCallback with standard settings.
//
// The value is logged with the provided message and then returned unchanged, making this
// useful for debugging and monitoring values in a ReaderIO computation pipeline.
//
// Type Parameters:
// - A: The type of value to log and pass through
//
// Parameters:
// - message: A descriptive message to include in the log entry
//
// Returns:
// - A Kleisli arrow that logs the value at Info level and returns it unchanged
//
// Example:
//
// pipeline := F.Pipe3(
// fetchUser(123),
// Chain(SLog[User]("Fetched user")),
// Map(func(u User) string { return u.Name }),
// Chain(SLog[string]("Extracted name")),
// )
//
// result := pipeline(context.Background())()
// // Logs: "Fetched user" value={ID:123 Name:"Alice"}
// // Logs: "Extracted name" value="Alice"
//
//go:inline
func SLog[A any](message string) Kleisli[A, A] {
return SLogWithCallback[A](slog.LevelInfo, logging.GetLoggerFromContext, message)

View File

@@ -19,6 +19,67 @@ import (
"github.com/IBM/fp-go/v2/readerio"
)
// TailRec implements stack-safe tail recursion for the ReaderIO monad.
//
// This function enables recursive computations that depend on a [context.Context] and
// perform side effects, without risking stack overflow. It uses an iterative loop to
// execute the recursion, making it safe for deep or unbounded recursion.
//
// The function takes a Kleisli arrow that returns Trampoline[A, B]:
// - Bounce(A): Continue recursion with the new state A
// - Land(B): Terminate recursion and return the final result B
//
// Type Parameters:
// - A: The state type that changes during recursion
// - B: The final result type when recursion terminates
//
// Parameters:
// - f: A Kleisli arrow (A => ReaderIO[Trampoline[A, B]]) that controls recursion flow
//
// Returns:
// - A Kleisli arrow (A => ReaderIO[B]) that executes the recursion safely
//
// Example - Countdown:
//
// countdownStep := func(n int) ReaderIO[tailrec.Trampoline[int, string]] {
// return func(ctx context.Context) IO[tailrec.Trampoline[int, string]] {
// return func() tailrec.Trampoline[int, string] {
// if n <= 0 {
// return tailrec.Land[int]("Done!")
// }
// return tailrec.Bounce[string](n - 1)
// }
// }
// }
//
// countdown := TailRec(countdownStep)
// result := countdown(10)(context.Background())() // Returns "Done!"
//
// Example - Sum with context:
//
// type SumState struct {
// numbers []int
// total int
// }
//
// sumStep := func(state SumState) ReaderIO[tailrec.Trampoline[SumState, int]] {
// return func(ctx context.Context) IO[tailrec.Trampoline[SumState, int]] {
// return func() tailrec.Trampoline[SumState, int] {
// if len(state.numbers) == 0 {
// return tailrec.Land[SumState](state.total)
// }
// return tailrec.Bounce[int](SumState{
// numbers: state.numbers[1:],
// total: state.total + state.numbers[0],
// })
// }
// }
// }
//
// sum := TailRec(sumStep)
// result := sum(SumState{numbers: []int{1, 2, 3, 4, 5}})(context.Background())()
// // Returns 15, safe even for very large slices
//
//go:inline
func TailRec[A, B any](f Kleisli[A, Trampoline[A, B]]) Kleisli[A, B] {
return readerio.TailRec(f)

View File

@@ -20,20 +20,85 @@ import (
RG "github.com/IBM/fp-go/v2/retry/generic"
)
// Retrying retries a ReaderIO computation according to a retry policy.
//
// This function implements a retry mechanism for operations that depend on a [context.Context]
// and perform side effects (IO). The retry loop continues until one of the following occurs:
// - The action succeeds and the check function returns false (no retry needed)
// - The retry policy returns None (retry limit reached)
// - The check function returns false (indicating success or a non-retryable condition)
//
// Type Parameters:
// - A: The type of the value produced by the action
//
// Parameters:
//
// - policy: A RetryPolicy that determines when and how long to wait between retries.
// The policy receives a RetryStatus on each iteration and returns an optional delay.
// If it returns None, retrying stops. Common policies include LimitRetries,
// ExponentialBackoff, and CapDelay from the retry package.
//
// - action: A Kleisli arrow that takes a RetryStatus and returns a ReaderIO[A].
// This function is called on each retry attempt and receives information about the
// current retry state (iteration number, cumulative delay, etc.).
//
// - check: A predicate function that examines the result A and returns true if the
// operation should be retried, or false if it should stop. This allows you to
// distinguish between retryable conditions and successful/permanent results.
//
// Returns:
// - A ReaderIO[A] that, when executed with a context, will perform the retry logic
// and return the final result.
//
// Example:
//
// // Create a retry policy: exponential backoff with a cap, limited to 5 retries
// policy := M.Concat(
// retry.LimitRetries(5),
// retry.CapDelay(10*time.Second, retry.ExponentialBackoff(100*time.Millisecond)),
// )(retry.Monoid)
//
// // Action that fetches data, with retry status information
// fetchData := func(status retry.RetryStatus) ReaderIO[string] {
// return func(ctx context.Context) IO[string] {
// return func() string {
// // Simulate an operation that might fail
// if status.IterNumber < 3 {
// return "" // Empty result indicates failure
// }
// return "success"
// }
// }
// }
//
// // Check function: retry if result is empty
// shouldRetry := func(s string) bool {
// return s == ""
// }
//
// // Create the retrying computation
// retryingFetch := Retrying(policy, fetchData, shouldRetry)
//
// // Execute
// ctx := context.Background()
// result := retryingFetch(ctx)() // Returns "success" after 3 attempts
//
//go:inline
func Retrying[A any](
policy retry.RetryPolicy,
action Kleisli[retry.RetryStatus, A],
check func(A) bool,
check Predicate[A],
) ReaderIO[A] {
// get an implementation for the types
return RG.Retrying(
Chain[A, A],
Chain[retry.RetryStatus, A],
Of[A],
Chain[A, Trampoline[retry.RetryStatus, A]],
Map[retry.RetryStatus, Trampoline[retry.RetryStatus, A]],
Of[Trampoline[retry.RetryStatus, A]],
Of[retry.RetryStatus],
Delay[retry.RetryStatus],
TailRec,
policy,
action,
check,

View File

@@ -20,8 +20,10 @@ import (
"github.com/IBM/fp-go/v2/consumer"
"github.com/IBM/fp-go/v2/either"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/lazy"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readerio"
"github.com/IBM/fp-go/v2/tailrec"
@@ -75,4 +77,8 @@ type (
Either[E, A any] = either.Either[E, A]
Trampoline[B, L any] = tailrec.Trampoline[B, L]
Predicate[A any] = predicate.Predicate[A]
Void = function.Void
)

View File

@@ -402,7 +402,125 @@ result := pipeline(db)(ctx)()
## Practical Benefits
### 1. **Improved Testability**
### 1. **Performance: Eager Construction, Lazy Execution**
One of the most important but often overlooked benefits of point-free style is its performance characteristic: **the program structure is constructed eagerly (at definition time), but execution happens lazily (at runtime)**.
#### Construction Happens Once
When you define a pipeline using point-free style with `F.Flow`, `F.Pipe`, or function composition, the composition structure is built immediately at definition time:
```go
// Point-free style - composition built ONCE at definition time
var processUser = F.Flow3(
getDatabase,
SequenceReader[DatabaseConfig, Database],
applyConfig(dbConfig),
)
// The pipeline structure is now fixed in memory
```
#### Execution Happens on Demand
The actual computation only runs when you provide the final parameters and invoke the result:
```go
// Execute multiple times - only execution cost, no re-composition
result1 := processUser(ctx1)() // Fast - reuses pre-built pipeline
result2 := processUser(ctx2)() // Fast - reuses pre-built pipeline
result3 := processUser(ctx3)() // Fast - reuses pre-built pipeline
```
#### Performance Benefit for Repeated Execution
If a flow is executed multiple times, the point-free style is significantly more efficient because:
1. **Composition overhead is paid once** - The function composition happens at definition time
2. **No re-interpretation** - Each execution doesn't need to rebuild the pipeline
3. **Memory efficiency** - The composed function is created once and reused
4. **Better for hot paths** - Ideal for high-frequency operations
#### Comparison: Point-Free vs. Imperative
```go
// Imperative style - reconstruction on EVERY call
func processUserImperative(ctx context.Context) Either[error, Database] {
// This function body is re-interpreted/executed every time
dbComp := getDatabase()(ctx)()
if dbReader, err := either.Unwrap(dbComp); err != nil {
return Left[Database](err)
}
db := dbReader(dbConfig)
// ... manual composition happens on every invocation
return Right[error](db)
}
// Point-free style - composition built ONCE
var processUserPointFree = F.Flow3(
getDatabase,
SequenceReader[DatabaseConfig, Database],
applyConfig(dbConfig),
)
// Benchmark scenario: 1000 executions
for i := 0; i < 1000; i++ {
// Imperative: pays composition cost 1000 times
result := processUserImperative(ctx)()
// Point-free: pays composition cost once, execution cost 1000 times
result := processUserPointFree(ctx)()
}
```
#### When This Matters Most
The performance benefit of eager construction is particularly important for:
- **High-frequency operations** - APIs, event handlers, request processors
- **Batch processing** - Same pipeline processes many items
- **Long-running services** - Pipelines defined once at startup, executed millions of times
- **Hot code paths** - Performance-critical sections that run repeatedly
- **Stream processing** - Processing continuous data streams
#### Example: API Handler
```go
// Define pipeline once at application startup
var handleUserRequest = F.Flow4(
parseRequest,
SequenceReader[Database, UserRequest],
applyDatabase(db),
Chain(validateAndProcess),
)
// Execute thousands of times per second
func apiHandler(w http.ResponseWriter, r *http.Request) {
// No composition overhead - just execution
result := handleUserRequest(r.Context())()
// ... handle result
}
```
#### Memory and CPU Efficiency
```go
// Point-free: O(1) composition overhead
var pipeline = F.Flow5(step1, step2, step3, step4, step5)
// Composed once, stored in memory
// Execute N times: O(N) execution cost only
for i := 0; i < N; i++ {
result := pipeline(input[i])
}
// Imperative: O(N) composition + execution cost
for i := 0; i < N; i++ {
// Composition logic runs every iteration
result := step5(step4(step3(step2(step1(input[i])))))
}
```
### 2. **Improved Testability**
Inject test dependencies easily:
@@ -418,7 +536,7 @@ testQuery := queryWithDB(testDB)
// Same computation, different dependencies
```
### 2. **Better Separation of Concerns**
### 3. **Better Separation of Concerns**
Separate configuration from execution:
@@ -431,7 +549,7 @@ computation := sequenced(cfg)
result := computation(ctx)()
```
### 3. **Enhanced Composability**
### 4. **Enhanced Composability**
Build complex pipelines from simple pieces:
@@ -444,7 +562,7 @@ var processUser = F.Flow4(
)
```
### 4. **Reduced Boilerplate**
### 5. **Reduced Boilerplate**
No need to manually thread parameters:
@@ -651,6 +769,7 @@ var processUser = func(userID string) ReaderIOResult[ProcessedUser] {
5. **Reusability** increases as computations can be specialized early
6. **Testability** improves through easy dependency injection
7. **Separation of concerns** is clearer (configuration vs. execution)
8. **Performance benefit**: Eager construction (once) + lazy execution (many times) = efficiency for repeated operations
## When to Use Sequence

View File

@@ -42,6 +42,42 @@ func WithContext[A any](ma ReaderIOResult[A]) ReaderIOResult[A] {
}
}
// WithContextK wraps a Kleisli arrow with context cancellation checking.
// This ensures that the computation checks for context cancellation before executing,
// providing a convenient way to add cancellation awareness to Kleisli arrows.
//
// This is particularly useful when composing multiple Kleisli arrows where each step
// should respect context cancellation.
//
// Type Parameters:
// - A: The input type of the Kleisli arrow
// - B: The output type of the Kleisli arrow
//
// Parameters:
// - f: The Kleisli arrow to wrap with context checking
//
// Returns:
// - A Kleisli arrow that checks for cancellation before executing
//
// Example:
//
// fetchUser := func(id int) ReaderIOResult[User] {
// return func(ctx context.Context) IOResult[User] {
// return func() Result[User] {
// // Long-running operation
// return result.Of(User{ID: id})
// }
// }
// }
//
// // Wrap with context checking
// safeFetch := WithContextK(fetchUser)
//
// // If context is cancelled, returns immediately without executing fetchUser
// ctx, cancel := context.WithCancel(context.Background())
// cancel() // Cancel immediately
// result := safeFetch(123)(ctx)() // Returns context.Canceled error
//
//go:inline
func WithContextK[A, B any](f Kleisli[A, B]) Kleisli[A, B] {
return F.Flow2(

View File

@@ -0,0 +1,60 @@
package readerioresult
import (
"time"
"github.com/IBM/fp-go/v2/circuitbreaker"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/retry"
)
type (
ClosedState = circuitbreaker.ClosedState
Env[T any] = Pair[IORef[circuitbreaker.BreakerState], ReaderIOResult[T]]
CircuitBreaker[T any] = State[Env[T], ReaderIOResult[T]]
)
func MakeCircuitBreaker[T any](
currentTime IO[time.Time],
closedState ClosedState,
checkError option.Kleisli[error, error],
policy retry.RetryPolicy,
metrics circuitbreaker.Metrics,
) CircuitBreaker[T] {
return circuitbreaker.MakeCircuitBreaker[error, T](
Left,
ChainFirstIOK,
ChainFirstLeftIOK,
FromIO,
Flap,
Flatten,
currentTime,
closedState,
circuitbreaker.MakeCircuitBreakerError,
checkError,
policy,
metrics,
)
}
func MakeSingletonBreaker[T any](
currentTime IO[time.Time],
closedState ClosedState,
checkError option.Kleisli[error, error],
policy retry.RetryPolicy,
metrics circuitbreaker.Metrics,
) Operator[T, T] {
return circuitbreaker.MakeSingletonBreaker(
MakeCircuitBreaker[T](
currentTime,
closedState,
checkError,
policy,
metrics,
),
closedState,
)
}

View File

@@ -0,0 +1,246 @@
# Circuit Breaker Documentation
## Overview
The `circuitbreaker.go` file provides a circuit breaker implementation for the `readerioresult` package. A circuit breaker is a design pattern used to detect failures and prevent cascading failures in distributed systems by temporarily blocking operations that are likely to fail.
## Package
```go
package readerioresult
```
This is part of the `context/readerioresult` package, which provides functional programming abstractions for operations that:
- Depend on a `context.Context` (Reader aspect)
- Perform side effects (IO aspect)
- Can fail with an `error` (Result/Either aspect)
## Type Definitions
### ClosedState
```go
type ClosedState = circuitbreaker.ClosedState
```
A type alias for the circuit breaker's closed state. When the circuit is closed, requests are allowed to pass through normally. The closed state tracks success and failure counts to determine when to open the circuit.
### Env[T any]
```go
type Env[T any] = Pair[IORef[circuitbreaker.BreakerState], ReaderIOResult[T]]
```
The environment type for the circuit breaker state machine. It contains:
- `IORef[circuitbreaker.BreakerState]`: A mutable reference to the current breaker state
- `ReaderIOResult[T]`: The computation to be protected by the circuit breaker
### CircuitBreaker[T any]
```go
type CircuitBreaker[T any] = State[Env[T], ReaderIOResult[T]]
```
The main circuit breaker type. It's a state monad that:
- Takes an environment containing the breaker state and the protected computation
- Returns a new environment and a wrapped computation that respects the circuit breaker logic
## Functions
### MakeCircuitBreaker
```go
func MakeCircuitBreaker[T any](
currentTime IO[time.Time],
closedState ClosedState,
checkError option.Kleisli[error, error],
policy retry.RetryPolicy,
logger io.Kleisli[string, string],
) CircuitBreaker[T]
```
Creates a new circuit breaker with the specified configuration.
#### Parameters
- **currentTime** `IO[time.Time]`: A function that returns the current time. This can be a virtual timer for testing purposes, allowing you to control time progression in tests.
- **closedState** `ClosedState`: The initial closed state configuration. This defines:
- Maximum number of failures before opening the circuit
- Time window for counting failures
- Other closed state parameters
- **checkError** `option.Kleisli[error, error]`: A function that determines whether an error should be counted as a failure. Returns:
- `Some(error)`: The error should be counted as a failure
- `None`: The error should be ignored (not counted as a failure)
This allows you to distinguish between transient errors (that should trigger circuit breaking) and permanent errors (that shouldn't).
- **policy** `retry.RetryPolicy`: The retry policy that determines:
- How long to wait before attempting to close the circuit (reset time)
- Exponential backoff or other delay strategies
- Maximum number of retry attempts
- **logger** `io.Kleisli[string, string]`: A logging function for circuit breaker events. Receives log messages and performs side effects (like writing to a log file or console).
#### Returns
A `CircuitBreaker[T]` that wraps computations with circuit breaker logic.
#### Circuit Breaker States
The circuit breaker operates in three states:
1. **Closed**: Normal operation. Requests pass through. Failures are counted.
- If failure threshold is exceeded, transitions to Open state
2. **Open**: Circuit is broken. Requests fail immediately without executing.
- After reset time expires, transitions to Half-Open state
3. **Half-Open** (Canary): Testing if the service has recovered.
- Allows a single test request (canary request)
- If canary succeeds, transitions to Closed state
- If canary fails, transitions back to Open state with extended reset time
#### Implementation Details
The function delegates to the generic `circuitbreaker.MakeCircuitBreaker` function, providing the necessary type-specific operations:
- **Left**: Creates a failed computation from an error
- **ChainFirstIOK**: Chains an IO operation that runs for side effects on success
- **ChainFirstLeftIOK**: Chains an IO operation that runs for side effects on failure
- **FromIO**: Lifts an IO computation into ReaderIOResult
- **Flap**: Applies a computation to a function
- **Flatten**: Flattens nested ReaderIOResult structures
These operations allow the generic circuit breaker to work with the `ReaderIOResult` monad.
## Usage Example
```go
import (
"context"
"fmt"
"time"
"github.com/IBM/fp-go/v2/circuitbreaker"
"github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioref"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/retry"
)
// Create a circuit breaker configuration
func createCircuitBreaker() readerioresult.CircuitBreaker[string] {
// Use real time
currentTime := func() time.Time { return time.Now() }
// Configure closed state: open after 5 failures in 10 seconds
closedState := circuitbreaker.MakeClosedState(5, 10*time.Second)
// Check all errors (count all as failures)
checkError := func(err error) option.Option[error] {
return option.Some(err)
}
// Retry policy: exponential backoff with max 5 retries
policy := retry.Monoid.Concat(
retry.LimitRetries(5),
retry.ExponentialBackoff(100*time.Millisecond),
)
// Simple logger
logger := func(msg string) io.IO[string] {
return func() string {
fmt.Println("Circuit Breaker:", msg)
return msg
}
}
return readerioresult.MakeCircuitBreaker[string](
currentTime,
closedState,
checkError,
policy,
logger,
)
}
// Use the circuit breaker
func main() {
cb := createCircuitBreaker()
// Create initial state
stateRef := ioref.NewIORef(circuitbreaker.InitialState())
// Your protected operation
operation := func(ctx context.Context) readerioresult.IOResult[string] {
return func() readerioresult.Result[string] {
// Your actual operation here
return result.Of("success")
}
}
// Apply circuit breaker
env := pair.MakePair(stateRef, operation)
result := cb(env)
// Execute the protected operation
ctx := context.Background()
protectedOp := pair.Tail(result)
outcome := protectedOp(ctx)()
}
```
## Testing with Virtual Timer
For testing, you can provide a virtual timer instead of `time.Now()`:
```go
// Virtual timer for testing
type VirtualTimer struct {
current time.Time
}
func (vt *VirtualTimer) Now() time.Time {
return vt.current
}
func (vt *VirtualTimer) Advance(d time.Duration) {
vt.current = vt.current.Add(d)
}
// Use in tests
func TestCircuitBreaker(t *testing.T) {
vt := &VirtualTimer{current: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}
currentTime := func() time.Time { return vt.Now() }
cb := readerioresult.MakeCircuitBreaker[string](
currentTime,
closedState,
checkError,
policy,
logger,
)
// Test circuit breaker behavior
// Advance time as needed
vt.Advance(5 * time.Second)
}
```
## Related Types
- `circuitbreaker.BreakerState`: The internal state of the circuit breaker (closed or open)
- `circuitbreaker.ClosedState`: Configuration for the closed state
- `retry.RetryPolicy`: Policy for retry delays and limits
- `option.Kleisli[error, error]`: Function type for error checking
- `io.Kleisli[string, string]`: Function type for logging
## See Also
- `circuitbreaker` package: Generic circuit breaker implementation
- `retry` package: Retry policies and strategies
- `readerioresult` package: Core ReaderIOResult monad operations

View File

@@ -0,0 +1,974 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package readerioresult
import (
"errors"
"log"
"sync"
"testing"
"time"
"github.com/IBM/fp-go/v2/array"
"github.com/IBM/fp-go/v2/circuitbreaker"
"github.com/IBM/fp-go/v2/ioref"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/retry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// VirtualTimer provides a controllable time source for testing
type VirtualTimer struct {
mu sync.Mutex
current time.Time
}
// NewVirtualTimer creates a new virtual timer starting at the given time
func NewVirtualTimer(start time.Time) *VirtualTimer {
return &VirtualTimer{current: start}
}
// Now returns the current virtual time
func (vt *VirtualTimer) Now() time.Time {
vt.mu.Lock()
defer vt.mu.Unlock()
return vt.current
}
// Advance moves the virtual time forward by the given duration
func (vt *VirtualTimer) Advance(d time.Duration) {
vt.mu.Lock()
defer vt.mu.Unlock()
vt.current = vt.current.Add(d)
}
// Set sets the virtual time to a specific value
func (vt *VirtualTimer) Set(t time.Time) {
vt.mu.Lock()
defer vt.mu.Unlock()
vt.current = t
}
// Helper function to create a test logger that collects messages
func testMetrics(_ *[]string) circuitbreaker.Metrics {
return circuitbreaker.MakeMetricsFromLogger("testMetrics", log.Default())
}
// Helper function to create a simple closed state
func testCBClosedState() circuitbreaker.ClosedState {
return circuitbreaker.MakeClosedStateCounter(3)
}
// Helper function to create a test retry policy
func testCBRetryPolicy() retry.RetryPolicy {
return retry.Monoid.Concat(
retry.LimitRetries(3),
retry.ExponentialBackoff(100*time.Millisecond),
)
}
// Helper function that checks all errors
func checkAllErrors(err error) option.Option[error] {
return option.Some(err)
}
// Helper function that ignores specific errors
func ignoreSpecificError(ignoredMsg string) func(error) option.Option[error] {
return func(err error) option.Option[error] {
if err.Error() == ignoredMsg {
return option.None[error]()
}
return option.Some(err)
}
}
// TestCircuitBreaker_SuccessfulOperation tests that successful operations
// pass through the circuit breaker without issues
func TestCircuitBreaker_SuccessfulOperation(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
// Create initial state
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
// Successful operation
operation := Of("success")
// Apply circuit breaker
env := pair.MakePair(stateRef, operation)
resultEnv := cb(env)
// Execute
ctx := t.Context()
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Of("success"), outcome)
}
// TestCircuitBreaker_SingleFailure tests that a single failure is handled
// but doesn't open the circuit
func TestCircuitBreaker_SingleFailure(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
expError := errors.New("operation failed")
// Failing operation
operation := Left[string](expError)
env := pair.MakePair(stateRef, operation)
resultEnv := cb(env)
ctx := t.Context()
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Left[string](expError), outcome)
// Circuit should still be closed after one failure
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(state))
}
// TestCircuitBreaker_OpensAfterThreshold tests that the circuit opens
// after exceeding the failure threshold
func TestCircuitBreaker_OpensAfterThreshold(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(), // Opens after 3 failures
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
expError := errors.New("operation failed")
// Failing operation
operation := Left[string](expError)
ctx := t.Context()
// Execute 3 failures to open the circuit
for range 3 {
env := pair.MakePair(stateRef, operation)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Left[string](expError), outcome)
}
// Circuit should now be open
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsOpen(state))
// Next request should fail immediately with circuit breaker error
env := pair.MakePair(stateRef, operation)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.True(t, result.IsLeft(outcome))
_, err := result.Unwrap(outcome)
var cbErr *circuitbreaker.CircuitBreakerError
assert.ErrorAs(t, err, &cbErr)
}
// TestCircuitBreaker_HalfOpenAfterResetTime tests that the circuit
// transitions to half-open state after the reset time
func TestCircuitBreaker_HalfOpenAfterResetTime(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
expError := errors.New("operation failed")
// Failing operation
failingOp := Left[string](expError)
ctx := t.Context()
// Open the circuit with 3 failures
for range 3 {
env := pair.MakePair(stateRef, failingOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Left[string](expError), outcome)
}
// Verify circuit is open
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsOpen(state))
// Advance time past the reset time (exponential backoff starts at 100ms)
vt.Advance(200 * time.Millisecond)
// Now create a successful operation for the canary request
successOp := Of("success")
// Next request should be a canary request
env := pair.MakePair(stateRef, successOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
// Canary should succeed
assert.Equal(t, result.Of("success"), outcome)
// Circuit should now be closed again
state = ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(state))
}
// TestCircuitBreaker_CanaryFailureExtendsOpenTime tests that a failed
// canary request extends the open time
func TestCircuitBreaker_CanaryFailureExtendsOpenTime(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
expError := errors.New("operation failed")
// Failing operation
failingOp := Left[string](expError)
ctx := t.Context()
// Open the circuit
for range 3 {
env := pair.MakePair(stateRef, failingOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Left[string](expError), outcome)
}
// Advance time to trigger canary
vt.Advance(200 * time.Millisecond)
// Canary request fails
env := pair.MakePair(stateRef, failingOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.True(t, result.IsLeft(outcome))
// Circuit should still be open
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsOpen(state))
// Immediate next request should fail with circuit breaker error
env = pair.MakePair(stateRef, failingOp)
resultEnv = cb(env)
protectedOp = pair.Tail(resultEnv)
outcome = protectedOp(ctx)()
assert.True(t, result.IsLeft(outcome))
_, err := result.Unwrap(outcome)
var cbErr *circuitbreaker.CircuitBreakerError
assert.ErrorAs(t, err, &cbErr)
}
// TestCircuitBreaker_IgnoredErrorsDoNotCount tests that errors filtered
// by checkError don't count toward opening the circuit
func TestCircuitBreaker_IgnoredErrorsDoNotCount(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
// Ignore "ignorable error"
checkError := ignoreSpecificError("ignorable error")
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkError,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
ignorableError := errors.New("ignorable error")
// Execute 5 ignorable errors
ignorableOp := Left[string](ignorableError)
for range 5 {
env := pair.MakePair(stateRef, ignorableOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Left[string](ignorableError), outcome)
}
// Circuit should still be closed
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(state))
realError := errors.New("real error")
// Now send a real error
realErrorOp := Left[string](realError)
env := pair.MakePair(stateRef, realErrorOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Left[string](realError), outcome)
// Circuit should still be closed (only 1 counted error)
state = ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(state))
}
// TestCircuitBreaker_MixedSuccessAndFailure tests the circuit behavior
// with a mix of successful and failed operations
func TestCircuitBreaker_MixedSuccessAndFailure(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
successOp := Of("success")
expError := errors.New("failure")
failOp := Left[string](expError)
// Pattern: fail, fail, success, fail
ops := array.From(failOp, failOp, successOp, failOp)
for _, op := range ops {
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
_ = protectedOp(ctx)()
}
// Circuit should still be closed (success resets the count)
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(state))
}
// TestCircuitBreaker_ConcurrentOperations tests that the circuit breaker
// handles concurrent operations correctly
func TestCircuitBreaker_ConcurrentOperations(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[int](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
var wg sync.WaitGroup
results := make([]Result[int], 10)
// Launch 10 concurrent operations
for i := range 10 {
wg.Add(1)
go func(idx int) {
defer wg.Done()
op := Of(idx)
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
results[idx] = protectedOp(ctx)()
}(i)
}
wg.Wait()
// All operations should succeed
for i, res := range results {
assert.True(t, result.IsRight(res), "Operation %d should succeed", i)
}
}
// TestCircuitBreaker_DifferentTypes tests that the circuit breaker works
// with different result types
func TestCircuitBreaker_DifferentTypes(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
// Test with int
cbInt := MakeCircuitBreaker[int](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRefInt := circuitbreaker.MakeClosedIORef(testCBClosedState())()
opInt := Of(42)
ctx := t.Context()
envInt := pair.MakePair(stateRefInt, opInt)
resultEnvInt := cbInt(envInt)
protectedOpInt := pair.Tail(resultEnvInt)
outcomeInt := protectedOpInt(ctx)()
assert.Equal(t, result.Of(42), outcomeInt)
// Test with struct
type User struct {
ID int
Name string
}
cbUser := MakeCircuitBreaker[User](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRefUser := circuitbreaker.MakeClosedIORef(testCBClosedState())()
opUser := Of(User{ID: 1, Name: "Alice"})
envUser := pair.MakePair(stateRefUser, opUser)
resultEnvUser := cbUser(envUser)
protectedOpUser := pair.Tail(resultEnvUser)
outcomeUser := protectedOpUser(ctx)()
require.Equal(t, result.Of(User{ID: 1, Name: "Alice"}), outcomeUser)
}
// TestCircuitBreaker_VirtualTimerAdvancement tests that the virtual timer
// correctly controls time-based behavior
func TestCircuitBreaker_VirtualTimerAdvancement(t *testing.T) {
startTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
vt := NewVirtualTimer(startTime)
// Verify initial time
assert.Equal(t, startTime, vt.Now())
// Advance by 1 hour
vt.Advance(1 * time.Hour)
assert.Equal(t, startTime.Add(1*time.Hour), vt.Now())
// Advance by 30 minutes
vt.Advance(30 * time.Minute)
assert.Equal(t, startTime.Add(90*time.Minute), vt.Now())
// Set to specific time
newTime := time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC)
vt.Set(newTime)
assert.Equal(t, newTime, vt.Now())
}
// TestCircuitBreaker_InitialState tests that the circuit starts in closed state
func TestCircuitBreaker_InitialState(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
// Check initial state is closed
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(state), "Circuit should start in closed state")
// First operation should execute normally
op := Of("first operation")
ctx := t.Context()
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Of("first operation"), outcome)
}
// TestCircuitBreaker_ErrorMessageFormat tests that circuit breaker errors
// have appropriate error messages
func TestCircuitBreaker_ErrorMessageFormat(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
expError := errors.New("service unavailable")
failOp := Left[string](expError)
// Open the circuit
for range 3 {
env := pair.MakePair(stateRef, failOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
_ = protectedOp(ctx)()
}
// Next request should fail with circuit breaker error
env := pair.MakePair(stateRef, failOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.True(t, result.IsLeft[string](outcome))
// Error message should indicate circuit breaker is open
_, err := result.Unwrap(outcome)
errMsg := err.Error()
assert.Contains(t, errMsg, "circuit", "Error should mention circuit breaker")
}
// RequestSpec defines a virtual request with timing and outcome information
type RequestSpec struct {
ID int // Unique identifier for the request
StartTime time.Duration // Virtual start time relative to test start
Duration time.Duration // How long the request takes to execute
ShouldFail bool // Whether this request should fail
}
// RequestResult captures the outcome of a request execution
type RequestResult struct {
ID int
StartTime time.Time
EndTime time.Time
Success bool
Error error
CircuitBreakerError bool // True if failed due to circuit breaker being open
}
// TestCircuitBreaker_ConcurrentBatchWithThresholdExceeded tests a complex
// concurrent scenario where:
// 1. Initial requests succeed
// 2. A batch of failures exceeds the threshold, opening the circuit
// 3. Subsequent requests fail immediately due to open circuit
// 4. After timeout, a canary request succeeds
// 5. Following requests succeed again
func TestCircuitBreaker_ConcurrentBatchWithThresholdExceeded(t *testing.T) {
startTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
vt := NewVirtualTimer(startTime)
var logMessages []string
// Circuit opens after 3 failures, with exponential backoff starting at 100ms
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(), // Opens after 3 failures
checkAllErrors,
testCBRetryPolicy(), // 100ms initial backoff
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
// Define the request sequence
// Phase 1: Initial successes (0-100ms)
// Phase 2: Failures that exceed threshold (100-200ms) - should open circuit
// Phase 3: Requests during open circuit (200-300ms) - should fail immediately
// Phase 4: After timeout (400ms+) - canary succeeds, then more successes
requests := []RequestSpec{
// Phase 1: Initial successful requests
{ID: 1, StartTime: 0 * time.Millisecond, Duration: 10 * time.Millisecond, ShouldFail: false},
{ID: 2, StartTime: 20 * time.Millisecond, Duration: 10 * time.Millisecond, ShouldFail: false},
// Phase 2: Sequential failures that exceed threshold (3 failures)
{ID: 3, StartTime: 100 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: true},
{ID: 4, StartTime: 110 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: true},
{ID: 5, StartTime: 120 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: true},
{ID: 6, StartTime: 130 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: true},
// Phase 3: Requests during open circuit - should fail with circuit breaker error
{ID: 7, StartTime: 200 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
{ID: 8, StartTime: 210 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
{ID: 9, StartTime: 220 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
// Phase 4: After reset timeout (100ms backoff from last failure at ~125ms = ~225ms)
// Wait longer to ensure we're past the reset time
{ID: 10, StartTime: 400 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false}, // Canary succeeds
{ID: 11, StartTime: 410 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
{ID: 12, StartTime: 420 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
}
results := make([]RequestResult, len(requests))
// Execute requests sequentially but model them as if they were concurrent
// by advancing the virtual timer to each request's start time
for i, req := range requests {
// Set virtual time to request start time
vt.Set(startTime.Add(req.StartTime))
// Create the operation based on spec
var op ReaderIOResult[string]
if req.ShouldFail {
op = Left[string](errors.New("operation failed"))
} else {
op = Of("success")
}
// Apply circuit breaker
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
// Record start time
execStartTime := vt.Now()
// Execute the operation
outcome := protectedOp(ctx)()
// Advance time by operation duration
vt.Advance(req.Duration)
execEndTime := vt.Now()
// Analyze the result
isSuccess := result.IsRight(outcome)
var err error
var isCBError bool
if !isSuccess {
_, err = result.Unwrap(outcome)
var cbErr *circuitbreaker.CircuitBreakerError
isCBError = errors.As(err, &cbErr)
}
results[i] = RequestResult{
ID: req.ID,
StartTime: execStartTime,
EndTime: execEndTime,
Success: isSuccess,
Error: err,
CircuitBreakerError: isCBError,
}
}
// Verify Phase 1: Initial requests should succeed
assert.True(t, results[0].Success, "Request 1 should succeed")
assert.True(t, results[1].Success, "Request 2 should succeed")
// Verify Phase 2: Failures should be recorded (first 3 fail with actual error)
// The 4th might fail with CB error if circuit opened fast enough
assert.False(t, results[2].Success, "Request 3 should fail")
assert.False(t, results[3].Success, "Request 4 should fail")
assert.False(t, results[4].Success, "Request 5 should fail")
// At least the first 3 failures should be actual operation failures, not CB errors
actualFailures := 0
for i := 2; i <= 4; i++ {
if !results[i].CircuitBreakerError {
actualFailures++
}
}
assert.GreaterOrEqual(t, actualFailures, 3, "At least 3 actual operation failures should occur")
// Verify Phase 3: Requests during open circuit should fail with circuit breaker error
for i := 6; i <= 8; i++ {
assert.False(t, results[i].Success, "Request %d should fail during open circuit", results[i].ID)
assert.True(t, results[i].CircuitBreakerError, "Request %d should fail with circuit breaker error", results[i].ID)
}
// Verify Phase 4: After timeout, canary and subsequent requests should succeed
assert.True(t, results[9].Success, "Request 10 (canary) should succeed")
assert.True(t, results[10].Success, "Request 11 should succeed after circuit closes")
assert.True(t, results[11].Success, "Request 12 should succeed after circuit closes")
// Verify final state is closed
finalState := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(finalState), "Circuit should be closed at the end")
// Log summary for debugging
t.Logf("Test completed with %d requests", len(results))
successCount := 0
cbErrorCount := 0
actualErrorCount := 0
for _, r := range results {
if r.Success {
successCount++
} else if r.CircuitBreakerError {
cbErrorCount++
} else {
actualErrorCount++
}
}
t.Logf("Summary: %d successes, %d circuit breaker errors, %d actual errors",
successCount, cbErrorCount, actualErrorCount)
}
// TestCircuitBreaker_ConcurrentHighLoad tests circuit breaker behavior
// under high concurrent load with mixed success/failure patterns
func TestCircuitBreaker_ConcurrentHighLoad(t *testing.T) {
startTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
vt := NewVirtualTimer(startTime)
var logMessages []string
cb := MakeCircuitBreaker[int](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
// Create a large batch of 50 requests
// Pattern: success, success, fail, fail, fail, fail, success, success, ...
// This ensures we have initial successes, then failures to open circuit,
// then more requests that hit the open circuit
numRequests := 50
results := make([]bool, numRequests)
cbErrors := make([]bool, numRequests)
// Execute requests with controlled timing
for i := range numRequests {
// Advance time slightly for each request
vt.Advance(10 * time.Millisecond)
// Pattern: 2 success, 4 failures, repeat
// This ensures we exceed the threshold (3 failures) early on
shouldFail := (i%6) >= 2 && (i%6) < 6
var op ReaderIOResult[int]
if shouldFail {
op = Left[int](errors.New("simulated failure"))
} else {
op = Of(i)
}
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
results[i] = result.IsRight(outcome)
if !results[i] {
_, err := result.Unwrap(outcome)
var cbErr *circuitbreaker.CircuitBreakerError
cbErrors[i] = errors.As(err, &cbErr)
}
}
// Count outcomes
successCount := 0
failureCount := 0
cbErrorCount := 0
for i := range numRequests {
if results[i] {
successCount++
} else {
failureCount++
if cbErrors[i] {
cbErrorCount++
}
}
}
t.Logf("High load test: %d total requests", numRequests)
t.Logf("Results: %d successes, %d failures (%d circuit breaker errors)",
successCount, failureCount, cbErrorCount)
// Verify that circuit breaker activated (some requests failed due to open circuit)
assert.Greater(t, cbErrorCount, 0, "Circuit breaker should have opened and blocked some requests")
// Verify that not all requests failed (some succeeded before circuit opened)
assert.Greater(t, successCount, 0, "Some requests should have succeeded")
}
// TestCircuitBreaker_TrueConcurrentRequests tests actual concurrent execution
// with proper synchronization
func TestCircuitBreaker_TrueConcurrentRequests(t *testing.T) {
startTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
vt := NewVirtualTimer(startTime)
var logMessages []string
cb := MakeCircuitBreaker[int](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
// Launch 20 concurrent requests
numRequests := 20
var wg sync.WaitGroup
results := make([]bool, numRequests)
cbErrors := make([]bool, numRequests)
// First, send some successful requests
for i := range 5 {
op := Of(i)
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
results[i] = result.IsRight(outcome)
}
// Now send concurrent failures to open the circuit
for i := 5; i < 10; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
op := Left[int](errors.New("concurrent failure"))
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
results[idx] = result.IsRight(outcome)
if !results[idx] {
_, err := result.Unwrap(outcome)
var cbErr *circuitbreaker.CircuitBreakerError
cbErrors[idx] = errors.As(err, &cbErr)
}
}(i)
}
wg.Wait()
// Now send more requests that should hit the open circuit
for i := 10; i < numRequests; i++ {
op := Of(i)
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
results[i] = result.IsRight(outcome)
if !results[i] {
_, err := result.Unwrap(outcome)
var cbErr *circuitbreaker.CircuitBreakerError
cbErrors[i] = errors.As(err, &cbErr)
}
}
// Count outcomes
successCount := 0
failureCount := 0
cbErrorCount := 0
for i := range numRequests {
if results[i] {
successCount++
} else {
failureCount++
if cbErrors[i] {
cbErrorCount++
}
}
}
t.Logf("Concurrent test: %d total requests", numRequests)
t.Logf("Results: %d successes, %d failures (%d circuit breaker errors)",
successCount, failureCount, cbErrorCount)
// Verify initial successes
assert.Equal(t, 5, successCount, "First 5 requests should succeed")
// Verify that circuit breaker opened and blocked some requests
assert.Greater(t, cbErrorCount, 0, "Circuit breaker should have opened and blocked some requests")
}

View File

@@ -2,12 +2,62 @@ package readerioresult
import "github.com/IBM/fp-go/v2/io"
// ChainConsumer chains a consumer function into a ReaderIOResult computation, discarding the original value.
// This is useful for performing side effects (like logging or metrics) that consume a value
// but don't produce a meaningful result. The computation continues with an empty struct.
//
// Type Parameters:
// - A: The type of value to consume
//
// Parameters:
// - c: A consumer function that performs side effects on the value
//
// Returns:
// - An Operator that chains the consumer and returns struct{}
//
// Example:
//
// logUser := func(u User) {
// log.Printf("Processing user: %s", u.Name)
// }
//
// pipeline := F.Pipe2(
// fetchUser(123),
// ChainConsumer(logUser),
// )
//
//go:inline
func ChainConsumer[A any](c Consumer[A]) Operator[A, struct{}] {
return ChainIOK(io.FromConsumerK(c))
return ChainIOK(io.FromConsumer(c))
}
// ChainFirstConsumer chains a consumer function into a ReaderIOResult computation, preserving the original value.
// This is useful for performing side effects (like logging or metrics) while passing the value through unchanged.
//
// The consumer is executed for its side effects, but the original value is returned.
//
// Type Parameters:
// - A: The type of value to consume and return
//
// Parameters:
// - c: A consumer function that performs side effects on the value
//
// Returns:
// - An Operator that chains the consumer and returns the original value
//
// Example:
//
// logUser := func(u User) {
// log.Printf("User: %s", u.Name)
// }
//
// pipeline := F.Pipe3(
// fetchUser(123),
// ChainFirstConsumer(logUser), // Logs but passes user through
// Map(func(u User) string { return u.Email }),
// )
//
//go:inline
func ChainFirstConsumer[A any](c Consumer[A]) Operator[A, A] {
return ChainFirstIOK(io.FromConsumerK(c))
return ChainFirstIOK(io.FromConsumer(c))
}

View File

@@ -44,11 +44,11 @@ var (
)
// Close closes an object
func Close[C io.Closer](c C) RIOE.ReaderIOResult[any] {
func Close[C io.Closer](c C) RIOE.ReaderIOResult[struct{}] {
return F.Pipe2(
c,
IOEF.Close[C],
RIOE.FromIOEither[any],
RIOE.FromIOEither[struct{}],
)
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package readerioresult
import (
"context"
RIOR "github.com/IBM/fp-go/v2/readerioresult"
)
// FilterOrElse filters a ReaderIOResult value based on a predicate.
// This is a convenience wrapper around readerioresult.FilterOrElse that fixes
// the context type to context.Context.
//
// If the predicate returns true for the Right value, it passes through unchanged.
// If the predicate returns false, it transforms the Right value into a Left (error) using onFalse.
// Left values are passed through unchanged.
//
// Parameters:
// - pred: A predicate function that tests the Right value
// - onFalse: A function that converts the failing value into an error
//
// Returns:
// - An Operator that filters ReaderIOResult values based on the predicate
//
// Example:
//
// // Validate that a number is positive
// isPositive := N.MoreThan(0)
// onNegative := func(n int) error { return fmt.Errorf("%d is not positive", n) }
//
// filter := readerioresult.FilterOrElse(isPositive, onNegative)
// result := filter(readerioresult.Right(42))(context.Background())()
//
//go:inline
func FilterOrElse[A any](pred Predicate[A], onFalse func(A) error) Operator[A, A] {
return RIOR.FilterOrElse[context.Context](pred, onFalse)
}

View File

@@ -966,6 +966,16 @@ func TapLeft[A, B any](f Kleisli[error, B]) Operator[A, A] {
return RIOR.TapLeft[A](WithContextK(f))
}
//go:inline
func ChainFirstLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
return RIOR.ChainFirstLeftIOK[A, context.Context](f)
}
//go:inline
func TapLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
return RIOR.TapLeftIOK[A, context.Context](f)
}
// Local transforms the context.Context environment before passing it to a ReaderIOResult computation.
//
// This is the Reader's local operation, which allows you to modify the environment

View File

@@ -235,7 +235,7 @@ func TestApPar(t *testing.T) {
func TestFromPredicate(t *testing.T) {
t.Run("Predicate true", func(t *testing.T) {
pred := FromPredicate(
func(x int) bool { return x > 0 },
N.MoreThan(0),
func(x int) error { return fmt.Errorf("value %d is not positive", x) },
)
result := pred(5)
@@ -244,7 +244,7 @@ func TestFromPredicate(t *testing.T) {
t.Run("Predicate false", func(t *testing.T) {
pred := FromPredicate(
func(x int) bool { return x > 0 },
N.MoreThan(0),
func(x int) error { return fmt.Errorf("value %d is not positive", x) },
)
result := pred(-5)

View File

@@ -121,7 +121,7 @@ import (
func Retrying[A any](
policy R.RetryPolicy,
action Kleisli[R.RetryStatus, A],
check func(Result[A]) bool,
check Predicate[Result[A]],
) ReaderIOResult[A] {
// delayWithCancel implements a context-aware delay mechanism for retry operations.
@@ -165,12 +165,14 @@ func Retrying[A any](
// get an implementation for the types
return RG.Retrying(
RIO.Chain[Result[A], Result[A]],
RIO.Chain[R.RetryStatus, Result[A]],
RIO.Of[Result[A]],
RIO.Chain[Result[A], Trampoline[R.RetryStatus, Result[A]]],
RIO.Map[R.RetryStatus, Trampoline[R.RetryStatus, Result[A]]],
RIO.Of[Trampoline[R.RetryStatus, Result[A]]],
RIO.Of[R.RetryStatus],
delayWithCancel,
RIO.TailRec,
policy,
WithContextK(action),
check,

View File

@@ -25,16 +25,20 @@ import (
"github.com/IBM/fp-go/v2/endomorphism"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioeither"
"github.com/IBM/fp-go/v2/ioref"
"github.com/IBM/fp-go/v2/lazy"
"github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/optics/prism"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readereither"
"github.com/IBM/fp-go/v2/readerio"
RIOR "github.com/IBM/fp-go/v2/readerioresult"
"github.com/IBM/fp-go/v2/readeroption"
"github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/state"
"github.com/IBM/fp-go/v2/tailrec"
)
@@ -140,4 +144,12 @@ type (
Lens[S, T any] = lens.Lens[S, T]
Trampoline[B, L any] = tailrec.Trampoline[B, L]
Predicate[A any] = predicate.Predicate[A]
Pair[A, B any] = pair.Pair[A, B]
IORef[A any] = ioref.IORef[A]
State[S, A any] = state.State[S, A]
)

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package readerresult
import (
"context"
RR "github.com/IBM/fp-go/v2/readerresult"
)
// FilterOrElse filters a ReaderResult value based on a predicate.
// This is a convenience wrapper around readerresult.FilterOrElse that fixes
// the context type to context.Context.
//
// If the predicate returns true for the Right value, it passes through unchanged.
// If the predicate returns false, it transforms the Right value into a Left (error) using onFalse.
// Left values are passed through unchanged.
//
// Parameters:
// - pred: A predicate function that tests the Right value
// - onFalse: A function that converts the failing value into an error
//
// Returns:
// - An Operator that filters ReaderResult values based on the predicate
//
// Example:
//
// // Validate that a number is positive
// isPositive := N.MoreThan(0)
// onNegative := func(n int) error { return fmt.Errorf("%d is not positive", n) }
//
// filter := readerresult.FilterOrElse(isPositive, onNegative)
// result := filter(readerresult.Right(42))(context.Background())
//
//go:inline
func FilterOrElse[A any](pred Predicate[A], onFalse func(A) error) Operator[A, A] {
return RR.FilterOrElse[context.Context](pred, onFalse)
}

View File

@@ -73,6 +73,48 @@ func FromPredicate[A any](pred func(A) bool, onFalse func(A) error) Kleisli[A, A
return readereither.FromPredicate[context.Context](pred, onFalse)
}
// OrElse recovers from a Left (error) by providing an alternative computation with access to context.Context.
// If the ReaderResult is Right, it returns the value unchanged.
// If the ReaderResult is Left, it applies the provided function to the error value,
// which returns a new ReaderResult that replaces the original.
//
// This is useful for error recovery, fallback logic, or chaining alternative computations
// that need access to the context (for cancellation, deadlines, or values).
//
// Example:
//
// // Recover with context-aware fallback
// recover := readerresult.OrElse(func(err error) readerresult.ReaderResult[int] {
// if err.Error() == "not found" {
// return func(ctx context.Context) result.Result[int] {
// // Could check ctx.Err() here
// return result.Of(42)
// }
// }
// return readerresult.Left[int](err)
// })
//
// OrElse recovers from a Left (error) by providing an alternative computation.
// If the ReaderResult is Right, it returns the value unchanged.
// If the ReaderResult is Left, it applies the provided function to the error value,
// which returns a new ReaderResult that replaces the original.
//
// This is useful for error recovery, fallback logic, or chaining alternative computations
// in the context of Reader computations with context.Context.
//
// Example:
//
// // Recover from specific errors with fallback values
// recover := readerresult.OrElse(func(err error) readerresult.ReaderResult[int] {
// if err.Error() == "not found" {
// return readerresult.Of[int](0) // default value
// }
// return readerresult.Left[int](err) // propagate other errors
// })
// result := recover(readerresult.Left[int](errors.New("not found")))(ctx) // Right(0)
// result := recover(readerresult.Of(42))(ctx) // Right(42) - unchanged
//
//go:inline
func OrElse[A any](onLeft Kleisli[error, A]) Kleisli[ReaderResult[A], A] {
return readereither.OrElse(F.Flow2(onLeft, WithContext))
}

View File

@@ -17,6 +17,7 @@ package readerresult
import (
"context"
"errors"
"testing"
E "github.com/IBM/fp-go/v2/either"
@@ -313,3 +314,69 @@ func TestMonadChainTo(t *testing.T) {
assert.False(t, secondExecuted, "second reader should not be executed on error")
})
}
func TestOrElse(t *testing.T) {
ctx := context.Background()
// Test OrElse with Right - should pass through unchanged
t.Run("Right value unchanged", func(t *testing.T) {
rightValue := Of(42)
recover := OrElse(func(err error) ReaderResult[int] {
return Left[int](errors.New("should not be called"))
})
res := recover(rightValue)(ctx)
assert.Equal(t, E.Of[error](42), res)
})
// Test OrElse with Left - should recover with fallback
t.Run("Left value recovered", func(t *testing.T) {
leftValue := Left[int](errors.New("not found"))
recoverWithFallback := OrElse(func(err error) ReaderResult[int] {
if err.Error() == "not found" {
return func(ctx context.Context) E.Either[error, int] {
return E.Of[error](99)
}
}
return Left[int](err)
})
res := recoverWithFallback(leftValue)(ctx)
assert.Equal(t, E.Of[error](99), res)
})
// Test OrElse with Left - should propagate other errors
t.Run("Left value propagated", func(t *testing.T) {
leftValue := Left[int](errors.New("fatal error"))
recoverWithFallback := OrElse(func(err error) ReaderResult[int] {
if err.Error() == "not found" {
return Of(99)
}
return Left[int](err)
})
res := recoverWithFallback(leftValue)(ctx)
assert.True(t, E.IsLeft(res))
val, err := E.UnwrapError(res)
assert.Equal(t, 0, val)
assert.Equal(t, "fatal error", err.Error())
})
// Test OrElse with context-aware recovery
t.Run("Context-aware recovery", func(t *testing.T) {
type ctxKey string
ctxWithValue := context.WithValue(ctx, ctxKey("fallback"), 123)
leftValue := Left[int](errors.New("use fallback"))
ctxRecover := OrElse(func(err error) ReaderResult[int] {
if err.Error() == "use fallback" {
return func(ctx context.Context) E.Either[error, int] {
if val := ctx.Value(ctxKey("fallback")); val != nil {
return E.Of[error](val.(int))
}
return E.Left[int](errors.New("no fallback"))
}
}
return Left[int](err)
})
res := ctxRecover(leftValue)(ctxWithValue)
assert.Equal(t, E.Of[error](123), res)
})
}

View File

@@ -24,11 +24,81 @@ import (
RG "github.com/IBM/fp-go/v2/retry/generic"
)
// Retrying retries a ReaderResult computation according to a retry policy with context awareness.
//
// This function implements a retry mechanism for operations that depend on a [context.Context]
// and can fail (Result). It respects context cancellation, meaning that if the context is
// cancelled during retry delays, the operation will stop immediately and return the cancellation error.
//
// The retry loop will continue until one of the following occurs:
// - The action succeeds and the check function returns false (no retry needed)
// - The retry policy returns None (retry limit reached)
// - The check function returns false (indicating success or a non-retryable failure)
// - The context is cancelled (returns context.Canceled or context.DeadlineExceeded)
//
// Type Parameters:
// - A: The type of the success value
//
// Parameters:
//
// - policy: A RetryPolicy that determines when and how long to wait between retries.
// The policy receives a RetryStatus on each iteration and returns an optional delay.
// If it returns None, retrying stops. Common policies include LimitRetries,
// ExponentialBackoff, and CapDelay from the retry package.
//
// - action: A Kleisli arrow that takes a RetryStatus and returns a ReaderResult[A].
// This function is called on each retry attempt and receives information about the
// current retry state (iteration number, cumulative delay, etc.). The action depends
// on a context.Context and produces a Result[A].
//
// - check: A predicate function that examines the Result[A] and returns true if the
// operation should be retried, or false if it should stop. This allows you to
// distinguish between retryable failures (e.g., network timeouts) and permanent
// failures (e.g., invalid input).
//
// Returns:
// - A ReaderResult[A] that, when executed with a context, will perform the retry
// logic with context cancellation support and return the final result.
//
// Example:
//
// // Create a retry policy: exponential backoff with a cap, limited to 5 retries
// policy := M.Concat(
// retry.LimitRetries(5),
// retry.CapDelay(10*time.Second, retry.ExponentialBackoff(100*time.Millisecond)),
// )(retry.Monoid)
//
// // Action that fetches data
// fetchData := func(status retry.RetryStatus) ReaderResult[string] {
// return func(ctx context.Context) Result[string] {
// if ctx.Err() != nil {
// return result.Left[string](ctx.Err())
// }
// if status.IterNumber < 3 {
// return result.Left[string](fmt.Errorf("temporary error"))
// }
// return result.Of("success")
// }
// }
//
// // Check function: retry on any error except context cancellation
// shouldRetry := func(r Result[string]) bool {
// return result.IsLeft(r) && !errors.Is(result.GetLeft(r), context.Canceled)
// }
//
// // Create the retrying computation
// retryingFetch := Retrying(policy, fetchData, shouldRetry)
//
// // Execute with a cancellable context
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// defer cancel()
// finalResult := retryingFetch(ctx)
//
//go:inline
func Retrying[A any](
policy R.RetryPolicy,
action Kleisli[R.RetryStatus, A],
check func(Result[A]) bool,
check Predicate[Result[A]],
) ReaderResult[A] {
// delayWithCancel implements a context-aware delay mechanism for retry operations.
@@ -70,12 +140,14 @@ func Retrying[A any](
// get an implementation for the types
return RG.Retrying(
RD.Chain[context.Context, Result[A], Result[A]],
RD.Chain[context.Context, R.RetryStatus, Result[A]],
RD.Of[context.Context, Result[A]],
RD.Chain[context.Context, Result[A], Trampoline[R.RetryStatus, Result[A]]],
RD.Map[context.Context, R.RetryStatus, Trampoline[R.RetryStatus, Result[A]]],
RD.Of[context.Context, Trampoline[R.RetryStatus, Result[A]]],
RD.Of[context.Context, R.RetryStatus],
delayWithCancel,
RD.TailRec,
policy,
WithContextK(action),
check,

View File

@@ -48,6 +48,7 @@ import (
"github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/optics/prism"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readereither"
"github.com/IBM/fp-go/v2/result"
@@ -68,4 +69,5 @@ type (
Prism[S, T any] = prism.Prism[S, T]
Lens[S, T any] = lens.Lens[S, T]
Trampoline[A, B any] = tailrec.Trampoline[A, B]
Predicate[A any] = predicate.Predicate[A]
)

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2024 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package statereaderioresult
import (
"context"
"github.com/IBM/fp-go/v2/statereaderioeither"
)
// FilterOrElse filters a StateReaderIOResult value based on a predicate.
// This is a convenience wrapper around statereaderioeither.FilterOrElse that fixes
// the context type to context.Context and the error type to error.
//
// If the predicate returns true for the Right value, it passes through unchanged.
// If the predicate returns false, it transforms the Right value into a Left (error) using onFalse.
// Left values are passed through unchanged.
//
// Parameters:
// - pred: A predicate function that tests the Right value
// - onFalse: A function that converts the failing value into an error
//
// Returns:
// - An Operator that filters StateReaderIOResult values based on the predicate
//
// Example:
//
// type AppState struct {
// Counter int
// }
//
// // Validate that a number is positive
// isPositive := N.MoreThan(0)
// onNegative := func(n int) error { return fmt.Errorf("%d is not positive", n) }
//
// filter := statereaderioresult.FilterOrElse[AppState](isPositive, onNegative)
// result := filter(statereaderioresult.Right[AppState](42))(AppState{})(context.Background())()
//
//go:inline
func FilterOrElse[S, A any](pred Predicate[A], onFalse func(A) error) Operator[S, A, A] {
return statereaderioeither.FilterOrElse[S, context.Context](pred, onFalse)
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/IBM/fp-go/v2/ioresult"
"github.com/IBM/fp-go/v2/optics/iso/lens"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/state"
@@ -81,4 +82,6 @@ type (
// Operator represents a function that transforms one StateReaderIOResult into another.
// This is commonly used for building composable operations via Map, Chain, etc.
Operator[S, A, B any] = Reader[StateReaderIOResult[S, A], StateReaderIOResult[S, B]]
Predicate[A any] = predicate.Predicate[A]
)

View File

@@ -9,9 +9,18 @@ import (
)
type (
Option[T any] = option.Option[T]
Result[T any] = result.Result[T]
IOResult[T any] = ioresult.IOResult[T]
IOOption[T any] = iooption.IOOption[T]
// Option represents an optional value that may or may not be present.
Option[T any] = option.Option[T]
// Result represents a computation that may fail with an error.
Result[T any] = result.Result[T]
// IOResult represents a synchronous computation that may fail with an error.
IOResult[T any] = ioresult.IOResult[T]
// IOOption represents a synchronous computation that may not produce a value.
IOOption[T any] = iooption.IOOption[T]
// Entry represents a key-value pair in a record/map structure.
Entry[K comparable, V any] = record.Entry[K, V]
)

View File

@@ -394,12 +394,12 @@ func UnwrapError[A any](ma Either[error, A]) (A, error) {
// Example:
//
// isPositive := either.FromPredicate(
// func(x int) bool { return x > 0 },
// N.MoreThan(0),
// func(x int) error { return errors.New("not positive") },
// )
// result := isPositive(42) // Right(42)
// result := isPositive(-1) // Left(error)
func FromPredicate[E, A any](pred func(A) bool, onFalse func(A) E) func(A) Either[E, A] {
func FromPredicate[E, A any](pred Predicate[A], onFalse func(A) E) Kleisli[E, A, A] {
return func(a A) Either[E, A] {
if pred(a) {
return Right[E](a)
@@ -416,7 +416,7 @@ func FromPredicate[E, A any](pred func(A) bool, onFalse func(A) E) func(A) Eithe
// result := either.FromNillable[int](errors.New("nil"))(ptr) // Left(error)
// val := 42
// result := either.FromNillable[int](errors.New("nil"))(&val) // Right(&42)
func FromNillable[A, E any](e E) func(*A) Either[E, *A] {
func FromNillable[A, E any](e E) Kleisli[E, *A, *A] {
return FromPredicate(F.IsNonNil[A], F.Constant1[*A](e))
}
@@ -450,7 +450,7 @@ func Reduce[E, A, B any](f func(B, A) B, initial B) func(Either[E, A]) B {
// return either.Right[string](99)
// })
// result := alternative(either.Left[int](errors.New("fail"))) // Right(99)
func AltW[E, E1, A any](that Lazy[Either[E1, A]]) func(Either[E, A]) Either[E1, A] {
func AltW[E, E1, A any](that Lazy[Either[E1, A]]) Kleisli[E1, Either[E, A], A] {
return Fold(F.Ignore1of1[E](that), Right[E1, A])
}
@@ -466,16 +466,29 @@ func Alt[E, A any](that Lazy[Either[E, A]]) Operator[E, A, A] {
return AltW[E](that)
}
// OrElse recovers from a Left by providing an alternative computation.
// OrElse recovers from a Left (error) by providing an alternative computation.
// If the Either is Right, it returns the value unchanged.
// If the Either is Left, it applies the provided function to the error value,
// which returns a new Either that replaces the original.
//
// This is useful for error recovery, fallback logic, or chaining alternative computations.
// The error type can be widened from E1 to E2, allowing transformation of error types.
//
// Example:
//
// // Recover from specific errors with fallback values
// recover := either.OrElse(func(err error) either.Either[error, int] {
// return either.Right[error](0) // default value
// if err.Error() == "not found" {
// return either.Right[error](0) // default value
// }
// return either.Left[int](err) // propagate other errors
// })
// result := recover(either.Left[int](errors.New("fail"))) // Right(0)
func OrElse[E, A any](onLeft Kleisli[E, E, A]) Operator[E, A, A] {
return Fold(onLeft, Of[E, A])
// result := recover(either.Left[int](errors.New("not found"))) // Right(0)
// result := recover(either.Right[error](42)) // Right(42) - unchanged
//
//go:inline
func OrElse[E1, E2, A any](onLeft Kleisli[E2, E1, A]) Kleisli[E2, Either[E1, A], A] {
return Fold(onLeft, Of[E2, A])
}
// ToType attempts to convert an any value to a specific type, returning Either.

View File

@@ -160,6 +160,7 @@ func TestToError(t *testing.T) {
// Test OrElse
func TestOrElse(t *testing.T) {
// Test basic recovery from Left
recover := OrElse(func(e error) Either[error, int] {
return Right[error](0)
})
@@ -167,8 +168,85 @@ func TestOrElse(t *testing.T) {
result := recover(Left[int](errors.New("error")))
assert.Equal(t, Right[error](0), result)
// Test Right value passes through unchanged
result = recover(Right[error](42))
assert.Equal(t, Right[error](42), result)
// Test selective recovery - recover some errors, propagate others
selectiveRecover := OrElse(func(err error) Either[error, int] {
if err.Error() == "not found" {
return Right[error](0) // default value for "not found"
}
return Left[int](err) // propagate other errors
})
assert.Equal(t, Right[error](0), selectiveRecover(Left[int](errors.New("not found"))))
permissionErr := errors.New("permission denied")
assert.Equal(t, Left[int](permissionErr), selectiveRecover(Left[int](permissionErr)))
// Test chaining multiple OrElse operations
firstRecover := OrElse(func(err error) Either[error, int] {
if err.Error() == "error1" {
return Right[error](1)
}
return Left[int](err)
})
secondRecover := OrElse(func(err error) Either[error, int] {
if err.Error() == "error2" {
return Right[error](2)
}
return Left[int](err)
})
assert.Equal(t, Right[error](1), F.Pipe1(Left[int](errors.New("error1")), firstRecover))
assert.Equal(t, Right[error](2), F.Pipe1(Left[int](errors.New("error2")), F.Flow2(firstRecover, secondRecover)))
}
// Test OrElseW
func TestOrElseW(t *testing.T) {
type ValidationError string
type AppError int
// Test with Right value - should return Right with widened error type
rightValue := Right[ValidationError]("success")
recoverValidation := OrElse(func(ve ValidationError) Either[AppError, string] {
return Left[string](AppError(400))
})
result := recoverValidation(rightValue)
assert.True(t, IsRight(result))
assert.Equal(t, "success", F.Pipe1(result, GetOrElse(F.Constant1[AppError](""))))
// Test with Left value - should apply recovery with new error type
leftValue := Left[string](ValidationError("invalid input"))
result = recoverValidation(leftValue)
assert.True(t, IsLeft(result))
_, leftVal := Unwrap(result)
assert.Equal(t, AppError(400), leftVal)
// Test error type conversion - ValidationError to AppError
convertError := OrElse(func(ve ValidationError) Either[AppError, int] {
return Left[int](AppError(len(ve)))
})
converted := convertError(Left[int](ValidationError("short")))
assert.True(t, IsLeft(converted))
_, leftConv := Unwrap(converted)
assert.Equal(t, AppError(5), leftConv)
// Test recovery to Right with widened error type
recoverToRight := OrElse(func(ve ValidationError) Either[AppError, int] {
if ve == "recoverable" {
return Right[AppError](99)
}
return Left[int](AppError(500))
})
assert.Equal(t, Right[AppError](99), recoverToRight(Left[int](ValidationError("recoverable"))))
assert.True(t, IsLeft(recoverToRight(Left[int](ValidationError("fatal")))))
// Test that Right values are preserved with widened error type
preservedRight := Right[ValidationError](42)
preserveRecover := OrElse(func(ve ValidationError) Either[AppError, int] {
return Left[int](AppError(999))
})
preserved := preserveRecover(preservedRight)
assert.Equal(t, Right[AppError](42), preserved)
}
// Test ToType

38
v2/either/filter.go Normal file
View File

@@ -0,0 +1,38 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package either
// FilterOrElse filters an Either value based on a predicate.
// If the Either is Right and the predicate returns true, returns the original Right.
// If the Either is Right and the predicate returns false, returns Left with the error from onFalse.
// If the Either is Left, returns the original Left without applying the predicate.
//
// This is useful for adding validation to Right values, converting them to Left if they don't meet certain criteria.
//
// Example:
//
// isPositive := N.MoreThan(0)
// onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
// filter := either.FilterOrElse(isPositive, onNegative)
//
// result1 := filter(either.Right[error](5)) // Right(5)
// result2 := filter(either.Right[error](-3)) // Left(error: "-3 is not positive")
// result3 := filter(either.Left[int](someError)) // Left(someError)
//
//go:inline
func FilterOrElse[E, A any](pred Predicate[A], onFalse func(A) E) Operator[E, A, A] {
return Chain(FromPredicate(pred, onFalse))
}

143
v2/either/filter_test.go Normal file
View File

@@ -0,0 +1,143 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package either
import (
"errors"
"fmt"
"testing"
N "github.com/IBM/fp-go/v2/number"
"github.com/stretchr/testify/assert"
)
func TestFilterOrElse(t *testing.T) {
// Test with positive predicate
isPositive := N.MoreThan(0)
onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
filter := FilterOrElse(isPositive, onNegative)
// Test Right value that passes predicate
result := filter(Right[error](5))
assert.Equal(t, Right[error](5), result)
// Test Right value that fails predicate
result = filter(Right[error](-3))
assert.True(t, IsLeft(result))
left, _ := UnwrapError(result)
assert.Equal(t, 0, left) // default value for int
// Test Right value at boundary (zero)
result = filter(Right[error](0))
assert.True(t, IsLeft(result))
// Test Left value (should pass through unchanged)
originalError := errors.New("original error")
result = filter(Left[int](originalError))
assert.Equal(t, Left[int](originalError), result)
}
func TestFilterOrElse_StringValidation(t *testing.T) {
// Test with string length validation
isNotEmpty := func(s string) bool { return len(s) > 0 }
onEmpty := func(s string) error { return errors.New("string is empty") }
filter := FilterOrElse(isNotEmpty, onEmpty)
// Test non-empty string
result := filter(Right[error]("hello"))
assert.Equal(t, Right[error]("hello"), result)
// Test empty string
result = filter(Right[error](""))
assert.True(t, IsLeft(result))
// Test Left value
originalError := errors.New("validation error")
result = filter(Left[string](originalError))
assert.Equal(t, Left[string](originalError), result)
}
func TestFilterOrElse_ComplexPredicate(t *testing.T) {
// Test with range validation
inRange := func(x int) bool { return x >= 10 && x <= 100 }
outOfRange := func(x int) error { return fmt.Errorf("%d is out of range [10, 100]", x) }
filter := FilterOrElse(inRange, outOfRange)
// Test value in range
result := filter(Right[error](50))
assert.Equal(t, Right[error](50), result)
// Test value below range
result = filter(Right[error](5))
assert.True(t, IsLeft(result))
// Test value above range
result = filter(Right[error](150))
assert.True(t, IsLeft(result))
// Test boundary values
result = filter(Right[error](10))
assert.Equal(t, Right[error](10), result)
result = filter(Right[error](100))
assert.Equal(t, Right[error](100), result)
}
func TestFilterOrElse_ChainedFilters(t *testing.T) {
// Test chaining multiple filters
isPositive := N.MoreThan(0)
onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
isEven := func(x int) bool { return x%2 == 0 }
onOdd := func(x int) error { return fmt.Errorf("%d is not even", x) }
filterPositive := FilterOrElse(isPositive, onNegative)
filterEven := FilterOrElse(isEven, onOdd)
// Test value that passes both filters
result := filterEven(filterPositive(Right[error](4)))
assert.Equal(t, Right[error](4), result)
// Test value that fails first filter
result = filterEven(filterPositive(Right[error](-2)))
assert.True(t, IsLeft(result))
// Test value that passes first but fails second filter
result = filterEven(filterPositive(Right[error](3)))
assert.True(t, IsLeft(result))
}
func TestFilterOrElse_WithStructs(t *testing.T) {
type User struct {
Name string
Age int
}
// Test with struct validation
isAdult := func(u User) bool { return u.Age >= 18 }
onMinor := func(u User) error { return fmt.Errorf("%s is not an adult (age: %d)", u.Name, u.Age) }
filter := FilterOrElse(isAdult, onMinor)
// Test adult user
adult := User{Name: "Alice", Age: 25}
result := filter(Right[error](adult))
assert.Equal(t, Right[error](adult), result)
// Test minor user
minor := User{Name: "Bob", Age: 16}
result = filter(Right[error](minor))
assert.True(t, IsLeft(result))
}

View File

@@ -48,7 +48,7 @@ import (
// eqError := eq.FromStrictEquals[error]()
//
// ab := strconv.Itoa
// bc := func(s string) bool { return len(s) > 0 }
// bc := S.IsNonEmpty
//
// testing.AssertLaws(t, eqError, eqInt, eqString, eq.FromStrictEquals[bool](), ab, bc)(42)
// }

View File

@@ -21,18 +21,36 @@ import (
"github.com/IBM/fp-go/v2/monoid"
"github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
)
// Option is a type alias for option.Option, provided for convenience
// when working with Either and Option together.
type (
Option[A any] = option.Option[A]
Lens[S, T any] = lens.Lens[S, T]
Endomorphism[T any] = endomorphism.Endomorphism[T]
Lazy[T any] = lazy.Lazy[T]
// Option is a type alias for option.Option, provided for convenience
// when working with Either and Option together.
Option[A any] = option.Option[A]
Kleisli[E, A, B any] = reader.Reader[A, Either[E, B]]
// Lens is an optic that focuses on a field of type T within a structure of type S.
Lens[S, T any] = lens.Lens[S, T]
// Endomorphism represents a function from a type to itself (T -> T).
Endomorphism[T any] = endomorphism.Endomorphism[T]
// Lazy represents a deferred computation that produces a value of type T.
Lazy[T any] = lazy.Lazy[T]
// Kleisli represents a Kleisli arrow for the Either monad.
// It's a function from A to Either[E, B], used for composing operations that may fail.
Kleisli[E, A, B any] = reader.Reader[A, Either[E, B]]
// Operator represents a function that transforms one Either into another.
// It takes an Either[E, A] and produces an Either[E, B].
Operator[E, A, B any] = Kleisli[E, Either[E, A], B]
Monoid[E, A any] = monoid.Monoid[Either[E, A]]
// Monoid represents a monoid structure for Either values.
Monoid[E, A any] = monoid.Monoid[Either[E, A]]
// Predicate represents a function that tests a value of type A and returns a boolean.
// It's commonly used for filtering and conditional operations.
Predicate[A any] = predicate.Predicate[A]
)

View File

@@ -37,6 +37,8 @@ type (
// var g endomorphism.Endomorphism[int] = increment
Endomorphism[A any] = func(A) A
// Kleisli represents a Kleisli arrow for endomorphisms.
// It's a function from A to Endomorphism[A], used for composing endomorphic operations.
Kleisli[A any] = func(A) Endomorphism[A]
// Operator represents a transformation from one endomorphism to another.

View File

@@ -31,3 +31,29 @@ import (
// err := errors.New("something went wrong")
// same := Identity(err) // returns the same error
var Identity = F.Identity[error]
// IsNonNil checks if an error is non-nil.
//
// This function provides a predicate for testing whether an error value is not nil.
// It's useful in functional programming contexts where you need a function to check
// error presence, such as in filter operations or conditional logic.
//
// Parameters:
// - err: The error to check
//
// Returns:
// - true if the error is not nil, false otherwise
//
// Example:
//
// err := errors.New("something went wrong")
// if IsNonNil(err) {
// // handle error
// }
//
// // Using in functional contexts
// errors := []error{nil, errors.New("error1"), nil, errors.New("error2")}
// nonNilErrors := F.Filter(IsNonNil)(errors) // [error1, error2]
func IsNonNil(err error) bool {
return err != nil
}

View File

@@ -129,7 +129,7 @@
//
// Working with predicates:
//
// isPositive := func(n int) bool { return n > 0 }
// isPositive := N.MoreThan(0)
// isEven := func(n int) bool { return n%2 == 0 }
//
// classify := Ternary(

View File

@@ -43,6 +43,7 @@ func Pipe1[F1 ~func(T0) T1, T0, T1 any](t0 T0, f1 F1) T1 {
// The final return value is the result of the last function application
//go:inline
func Flow1[F1 ~func(T0) T1, T0, T1 any](f1 F1) func(T0) T1 {
//go:inline
return func(t0 T0) T1 {
return Pipe1(t0, f1)
}
@@ -103,6 +104,7 @@ func Pipe2[F1 ~func(T0) T1, F2 ~func(T1) T2, T0, T1, T2 any](t0 T0, f1 F1, f2 F2
// The final return value is the result of the last function application
//go:inline
func Flow2[F1 ~func(T0) T1, F2 ~func(T1) T2, T0, T1, T2 any](f1 F1, f2 F2) func(T0) T2 {
//go:inline
return func(t0 T0) T2 {
return Pipe2(t0, f1, f2)
}
@@ -169,6 +171,7 @@ func Pipe3[F1 ~func(T0) T1, F2 ~func(T1) T2, F3 ~func(T2) T3, T0, T1, T2, T3 any
// The final return value is the result of the last function application
//go:inline
func Flow3[F1 ~func(T0) T1, F2 ~func(T1) T2, F3 ~func(T2) T3, T0, T1, T2, T3 any](f1 F1, f2 F2, f3 F3) func(T0) T3 {
//go:inline
return func(t0 T0) T3 {
return Pipe3(t0, f1, f2, f3)
}

View File

@@ -35,7 +35,7 @@ package function
//
// Example:
//
// isPositive := func(n int) bool { return n > 0 }
// isPositive := N.MoreThan(0)
// double := N.Mul(2)
// negate := func(n int) int { return -n }
//
@@ -45,7 +45,7 @@ package function
//
// // Classify numbers
// classify := Ternary(
// func(n int) bool { return n > 0 },
// N.MoreThan(0),
// Constant1[int, string]("positive"),
// Constant1[int, string]("non-positive"),
// )

View File

@@ -15,6 +15,47 @@
package function
// Void represents the unit type, a type with exactly one value.
//
// In functional programming, Void (also known as Unit) is used to represent
// the absence of meaningful information. It's similar to void in other languages,
// but as a value rather than the absence of a value.
//
// Common use cases:
// - As a return type for functions that perform side effects but don't return meaningful data
// - As a placeholder type parameter when a type is required but no data needs to be passed
// - In functional patterns where a value is required but the actual data is irrelevant
//
// Example:
//
// // Function that performs an action but returns no meaningful data
// func logMessage(msg string) Void {
// fmt.Println(msg)
// return VOID
// }
//
// // Using Void as a type parameter
// type Action = func() Void
type (
Void = struct{}
)
// VOID is the single inhabitant of the Void type.
//
// This constant represents the only possible value of type Void. Use it when you need
// to return or pass a Void value.
//
// Example:
//
// func doSomething() Void {
// // perform some action
// return VOID
// }
//
// // Ignoring the return value
// _ = doSomething()
var VOID Void = struct{}{}
// ToAny converts a value of any type to the any (interface{}) type.
//
// This function performs an explicit type conversion to the any type, which can be

View File

@@ -16,6 +16,11 @@
package identity
type (
Kleisli[A, B any] = func(A) B
// Kleisli represents a Kleisli arrow for the Identity monad.
// It's simply a function from A to B, as Identity has no computational context.
Kleisli[A, B any] = func(A) B
// Operator represents a function that transforms values.
// In the Identity monad, it's equivalent to Kleisli since there's no wrapping context.
Operator[A, B any] = Kleisli[A, B]
)

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package readerresult
import "github.com/IBM/fp-go/v2/either"
// FilterOrElse filters a context-aware ReaderResult value based on a predicate in an idiomatic style.
// If the ReaderResult computation succeeds and the predicate returns true, returns the original success value.
// If the ReaderResult computation succeeds and the predicate returns false, returns an error with the error from onFalse.
// If the ReaderResult computation fails, returns the original error without applying the predicate.
//
// This is the idiomatic version that returns an Operator for use in method chaining.
// It's useful for adding validation to successful context-aware computations, converting them to errors if they don't meet certain criteria.
//
// Example:
//
// import (
// CRR "github.com/IBM/fp-go/v2/idiomatic/context/readerresult"
// N "github.com/IBM/fp-go/v2/number"
// )
//
// isPositive := N.MoreThan(0)
// onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
//
// result := CRR.Of(5).
// Pipe(CRR.FilterOrElse(isPositive, onNegative))(ctx) // Ok(5)
//
// result2 := CRR.Of(-3).
// Pipe(CRR.FilterOrElse(isPositive, onNegative))(ctx) // Error(error: "-3 is not positive")
//
//go:inline
func FilterOrElse[A any](pred Predicate[A], onFalse func(A) error) Operator[A, A] {
return ChainEitherK(either.FromPredicate(pred, onFalse))
}

View File

@@ -393,6 +393,26 @@ func GetOrElse[A any](onLeft reader.Kleisli[context.Context, error, A]) func(Rea
return RR.GetOrElse(onLeft)
}
// OrElse recovers from a Left (error) by providing an alternative computation.
// If the ReaderResult is Right, it returns the value unchanged.
// If the ReaderResult is Left, it applies the provided function to the error value,
// which returns a new ReaderResult that replaces the original.
//
// This is the idiomatic version that works with context.Context-based ReaderResult.
// This is useful for error recovery, fallback logic, or chaining alternative computations.
//
// Example:
//
// // Recover from specific errors with fallback values
// recover := readerresult.OrElse(func(err error) readerresult.ReaderResult[int] {
// if err.Error() == "not found" {
// return readerresult.Of[int](0) // default value
// }
// return readerresult.Left[int](err) // propagate other errors
// })
// result := recover(readerresult.Left[int](errors.New("not found")))(ctx) // Right(0)
// result := recover(readerresult.Of(42))(ctx) // Right(42) - unchanged
//
//go:inline
func OrElse[A any](onLeft Kleisli[error, A]) Operator[A, A] {
return RR.OrElse(WithContextK(onLeft))

View File

@@ -25,6 +25,7 @@ import (
"github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/optics/prism"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/tailrec"
@@ -70,5 +71,9 @@ type (
// Prism represents an optic that focuses on a case of type A within a sum type S.
Prism[S, A any] = prism.Prism[S, A]
// Trampoline represents a tail-recursive computation that can be evaluated iteratively.
// It's used to implement stack-safe recursion.
Trampoline[A, B any] = tailrec.Trampoline[A, B]
Predicate[A any] = predicate.Predicate[A]
)

View File

@@ -460,7 +460,7 @@
// 5. Use FromPredicate for validation:
//
// positiveInt := result.FromPredicate(
// func(x int) bool { return x > 0 },
// N.MoreThan(0),
// func(x int) error { return fmt.Errorf("%d is not positive", x) },
// )
//

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ioresult
import "github.com/IBM/fp-go/v2/idiomatic/result"
// FilterOrElse filters an IOResult value based on a predicate in an idiomatic style.
// If the IOResult computation succeeds and the predicate returns true, returns the original success value.
// If the IOResult computation succeeds and the predicate returns false, returns an error with the error from onFalse.
// If the IOResult computation fails, returns the original error without applying the predicate.
//
// This is the idiomatic version that returns an Operator for use in method chaining.
// It's useful for adding validation to successful IO computations, converting them to errors if they don't meet certain criteria.
//
// Example:
//
// import (
// IO "github.com/IBM/fp-go/v2/idiomatic/ioresult"
// N "github.com/IBM/fp-go/v2/number"
// )
//
// isPositive := N.MoreThan(0)
// onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
//
// result := IO.Of(5).
// Pipe(IO.FilterOrElse(isPositive, onNegative))() // Ok(5)
//
// result2 := IO.Of(-3).
// Pipe(IO.FilterOrElse(isPositive, onNegative))() // Error(error: "-3 is not positive")
//
//go:inline
func FilterOrElse[A any](pred Predicate[A], onFalse func(A) error) Operator[A, A] {
return ChainResultK(result.FromPredicate(pred, onFalse))
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ioresult
import (
"errors"
"fmt"
"testing"
F "github.com/IBM/fp-go/v2/function"
N "github.com/IBM/fp-go/v2/number"
"github.com/stretchr/testify/assert"
)
func TestFilterOrElse(t *testing.T) {
// Test with positive predicate
isPositive := N.MoreThan(0)
onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
// Test value that passes predicate
result, err := F.Pipe2(5, Of, FilterOrElse(isPositive, onNegative))()
assert.NoError(t, err)
assert.Equal(t, 5, result)
// Test value that fails predicate
_, err = F.Pipe2(-3, Of, FilterOrElse(isPositive, onNegative))()
assert.Error(t, err)
assert.Equal(t, "-3 is not positive", err.Error())
// Test error value (should pass through unchanged)
originalError := errors.New("original error")
_, err = F.Pipe2(originalError, Left[int], FilterOrElse(isPositive, onNegative))()
assert.Error(t, err)
assert.Equal(t, originalError, err)
}
func TestFilterOrElse_WithChain(t *testing.T) {
// Test FilterOrElse in a chain with other IO operations
isPositive := N.MoreThan(0)
onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
double := func(x int) (int, error) { return x * 2, nil }
// Test successful chain
result, err := F.Pipe3(5, Of, FilterOrElse(isPositive, onNegative), ChainResultK(double))()
assert.NoError(t, err)
assert.Equal(t, 10, result)
// Test chain with filter failure
_, err = F.Pipe3(-5, Of, FilterOrElse(isPositive, onNegative), ChainResultK(double))()
assert.Error(t, err)
assert.Contains(t, err.Error(), "not positive")
}
func TestFilterOrElse_MultipleFilters(t *testing.T) {
// Test chaining multiple filters
isPositive := N.MoreThan(0)
onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
isEven := func(x int) bool { return x%2 == 0 }
onOdd := func(x int) error { return fmt.Errorf("%d is not even", x) }
// Test value that passes both filters
result, err := F.Pipe3(4, Of, FilterOrElse(isPositive, onNegative), FilterOrElse(isEven, onOdd))()
assert.NoError(t, err)
assert.Equal(t, 4, result)
// Test value that fails second filter
_, err = F.Pipe3(3, Of, FilterOrElse(isPositive, onNegative), FilterOrElse(isEven, onOdd))()
assert.Error(t, err)
assert.Contains(t, err.Error(), "not even")
}

View File

@@ -4,6 +4,7 @@ import (
"github.com/IBM/fp-go/v2/endomorphism"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/lazy"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/result"
)
@@ -36,4 +37,6 @@ type (
// Operator represents a transformation from IOResult[A] to IOResult[B].
// It is commonly used in function composition pipelines.
Operator[A, B any] = Kleisli[IOResult[A], B]
Predicate[A any] = predicate.Predicate[A]
)

View File

@@ -73,7 +73,7 @@ func BenchmarkChain(b *testing.B) {
func BenchmarkFilter(b *testing.B) {
v, ok := Some(42)
filter := Filter(func(x int) bool { return x > 0 })
filter := Filter(N.MoreThan(0))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -152,7 +152,7 @@ func BenchmarkDoBind(b *testing.B) {
// Benchmark conversions
func BenchmarkFromPredicate(b *testing.B) {
pred := FromPredicate(func(x int) bool { return x > 0 })
pred := FromPredicate(N.MoreThan(0))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {

View File

@@ -73,7 +73,7 @@
//
// Filter keeps values that satisfy a predicate:
//
// isPositive := Filter(func(x int) bool { return x > 0 })
// isPositive := Filter(N.MoreThan(0))
// result := isPositive(Some(5)) // (5, true)
// result := isPositive(Some(-1)) // (0, false)
//
@@ -127,7 +127,7 @@
//
// Convert predicates to Options:
//
// isPositive := FromPredicate(func(n int) bool { return n > 0 })
// isPositive := FromPredicate(N.MoreThan(0))
// result := isPositive(5) // (5, true)
// result := isPositive(-1) // (0, false)
//

View File

@@ -47,7 +47,7 @@ import (
//
// Example:
//
// isPositive := FromPredicate(func(n int) bool { return n > 0 })
// isPositive := FromPredicate(N.MoreThan(0))
// result := isPositive(5) // Some(5)
// result := isPositive(-1) // None
func FromPredicate[A any](pred func(A) bool) Kleisli[A, A] {
@@ -330,7 +330,7 @@ func Reduce[A, B any](f func(B, A) B, initial B) func(A, bool) B {
//
// Example:
//
// isPositive := Filter(func(x int) bool { return x > 0 })
// isPositive := Filter(N.MoreThan(0))
// result := isPositive(Some(5)) // Some(5)
// result := isPositive(Some(-1)) // None
// result := isPositive(None[int]()) // None

View File

@@ -130,20 +130,20 @@ func TestChainFirst(t *testing.T) {
// Test Filter
func TestFilter(t *testing.T) {
t.Run("positive case - predicate satisfied", func(t *testing.T) {
isPositive := Filter(func(x int) bool { return x > 0 })
isPositive := Filter(N.MoreThan(0))
// Should keep value when predicate is satisfied
AssertEq(Some(5))(isPositive(Some(5)))(t)
})
t.Run("negative case - predicate not satisfied", func(t *testing.T) {
isPositive := Filter(func(x int) bool { return x > 0 })
isPositive := Filter(N.MoreThan(0))
// Should return None when predicate fails
AssertEq(None[int]())(isPositive(Some(-1)))(t)
AssertEq(None[int]())(isPositive(Some(0)))(t)
})
t.Run("negative case - input is None", func(t *testing.T) {
isPositive := Filter(func(x int) bool { return x > 0 })
isPositive := Filter(N.MoreThan(0))
// Should return None when input is None
AssertEq(None[int]())(isPositive(None[int]()))(t)
})

View File

@@ -47,7 +47,7 @@ import (
// eqBool := eq.FromStrictEquals[bool]()
//
// ab := strconv.Itoa
// bc := func(s string) bool { return len(s) > 0 }
// bc := S.IsNonEmpty
//
// assert := AssertLaws(t, eqInt, eqString, eqBool, ab, bc)
// assert(42) // verifies laws hold for value 42

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package readerioresult
import "github.com/IBM/fp-go/v2/either"
// FilterOrElse filters a ReaderIOResult value based on a predicate in an idiomatic style.
// If the ReaderIOResult computation succeeds and the predicate returns true, returns the original success value.
// If the ReaderIOResult computation succeeds and the predicate returns false, returns an error with the error from onFalse.
// If the ReaderIOResult computation fails, returns the original error without applying the predicate.
//
// This is the idiomatic version that returns an Operator for use in method chaining.
// It's useful for adding validation to successful IO computations with dependencies, converting them to errors if they don't meet certain criteria.
//
// Example:
//
// import (
// RIO "github.com/IBM/fp-go/v2/idiomatic/readerioresult"
// N "github.com/IBM/fp-go/v2/number"
// )
//
// type Config struct {
// MaxValue int
// }
//
// isPositive := N.MoreThan(0)
// onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
//
// result := RIO.Of[Config](5).
// Pipe(RIO.FilterOrElse(isPositive, onNegative))(Config{MaxValue: 10})() // Ok(5)
//
// result2 := RIO.Of[Config](-3).
// Pipe(RIO.FilterOrElse(isPositive, onNegative))(Config{MaxValue: 10})() // Error(error: "-3 is not positive")
//
//go:inline
func FilterOrElse[R, A any](pred Predicate[A], onFalse func(A) error) Operator[R, A, A] {
return ChainEitherK[R](either.FromPredicate(pred, onFalse))
}

View File

@@ -16,9 +16,11 @@
package readerioresult
import (
"github.com/IBM/fp-go/v2/either"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/idiomatic/ioresult"
"github.com/IBM/fp-go/v2/internal/chain"
"github.com/IBM/fp-go/v2/internal/fromeither"
"github.com/IBM/fp-go/v2/internal/fromio"
"github.com/IBM/fp-go/v2/internal/fromreader"
"github.com/IBM/fp-go/v2/internal/functor"
@@ -265,67 +267,67 @@ func MonadTap[R, A, B any](fa ReaderIOResult[R, A], f Kleisli[R, A, B]) ReaderIO
return MonadChainFirst(fa, f)
}
// // MonadChainEitherK chains a computation that returns an Either into a ReaderIOResult.
// // The Either is automatically lifted into the ReaderIOResult context.
// //
// //go:inline
// func MonadChainEitherK[R, A, B any](ma ReaderIOResult[R, A], f either.Kleisli[A, B]) ReaderIOResult[R, B] {
// return fromeither.MonadChainEitherK(
// MonadChain[R, A, B],
// FromEither[R, B],
// ma,
// f,
// )
// }
// MonadChainEitherK chains a computation that returns an Either into a ReaderIOResult.
// The Either is automatically lifted into the ReaderIOResult context.
//
//go:inline
func MonadChainEitherK[R, A, B any](ma ReaderIOResult[R, A], f either.Kleisli[error, A, B]) ReaderIOResult[R, B] {
return fromeither.MonadChainEitherK(
MonadChain[R, A, B],
FromEither[R, B],
ma,
f,
)
}
// // ChainEitherK returns a function that chains an Either-returning function into ReaderIOResult.
// // This is the curried version of MonadChainEitherK.
// //
// //go:inline
// func ChainEitherK[R, A, B any](f either.Kleisli[A, B]) Operator[R, A, B] {
// return fromeither.ChainEitherK(
// Chain[R, A, B],
// FromEither[R, B],
// f,
// )
// }
// ChainEitherK returns a function that chains an Either-returning function into ReaderIOResult.
// This is the curried version of MonadChainEitherK.
//
//go:inline
func ChainEitherK[R, A, B any](f either.Kleisli[error, A, B]) Operator[R, A, B] {
return fromeither.ChainEitherK(
Chain[R, A, B],
FromEither[R, B],
f,
)
}
// // MonadChainFirstEitherK chains an Either-returning computation but keeps the original value.
// // Useful for validation or side effects that return Either.
// //
// //go:inline
// func MonadChainFirstEitherK[R, A, B any](ma ReaderIOResult[R, A], f either.Kleisli[A, B]) ReaderIOResult[R, A] {
// return fromeither.MonadChainFirstEitherK(
// MonadChain[R, A, A],
// MonadMap[R, B, A],
// FromEither[R, B],
// ma,
// f,
// )
// }
// MonadChainFirstEitherK chains an Either-returning computation but keeps the original value.
// Useful for validation or side effects that return Either.
//
//go:inline
func MonadChainFirstEitherK[R, A, B any](ma ReaderIOResult[R, A], f either.Kleisli[error, A, B]) ReaderIOResult[R, A] {
return fromeither.MonadChainFirstEitherK(
MonadChain[R, A, A],
MonadMap[R, B, A],
FromEither[R, B],
ma,
f,
)
}
// //go:inline
// func MonadTapEitherK[R, A, B any](ma ReaderIOResult[R, A], f either.Kleisli[A, B]) ReaderIOResult[R, A] {
// return MonadChainFirstEitherK(ma, f)
// }
//go:inline
func MonadTapEitherK[R, A, B any](ma ReaderIOResult[R, A], f either.Kleisli[error, A, B]) ReaderIOResult[R, A] {
return MonadChainFirstEitherK(ma, f)
}
// // ChainFirstEitherK returns a function that chains an Either computation while preserving the original value.
// // This is the curried version of MonadChainFirstEitherK.
// //
// //go:inline
// func ChainFirstEitherK[R, A, B any](f either.Kleisli[A, B]) Operator[R, A, A] {
// return fromeither.ChainFirstEitherK(
// Chain[R, A, A],
// Map[R, B, A],
// FromEither[R, B],
// f,
// )
// }
// ChainFirstEitherK returns a function that chains an Either computation while preserving the original value.
// This is the curried version of MonadChainFirstEitherK.
//
//go:inline
func ChainFirstEitherK[R, A, B any](f either.Kleisli[error, A, B]) Operator[R, A, A] {
return fromeither.ChainFirstEitherK(
Chain[R, A, A],
Map[R, B, A],
FromEither[R, B],
f,
)
}
// //go:inline
// func TapEitherK[R, A, B any](f either.Kleisli[A, B]) Operator[R, A, A] {
// return ChainFirstEitherK[R](f)
// }
//go:inline
func TapEitherK[R, A, B any](f either.Kleisli[error, A, B]) Operator[R, A, A] {
return ChainFirstEitherK[R](f)
}
// MonadChainReaderK chains a Reader-returning computation into a ReaderIOResult.
// The Reader is automatically lifted into the ReaderIOResult context.
@@ -698,13 +700,15 @@ func Flatten[R, A any](mma ReaderIOResult[R, ReaderIOResult[R, A]]) ReaderIOResu
return MonadChain(mma, function.Identity[ReaderIOResult[R, A]])
}
// // FromEither lifts an Either into a ReaderIOResult context.
// // The Either value is independent of any context or IO effects.
// //
// //go:inline
// func FromEither[R, A any](t either.Either[A]) ReaderIOResult[R, A] {
// return readerio.Of[R](t)
// }
// FromEither lifts an Either into a ReaderIOResult context.
// The Either value is independent of any context or IO effects.
func FromEither[R, A any](t either.Either[error, A]) ReaderIOResult[R, A] {
return func(r R) IOResult[A] {
return func() (A, error) {
return either.Unwrap(t)
}
}
}
// RightReader lifts a Reader into a ReaderIOResult, placing the result in the Right side.
func RightReader[R, A any](ma Reader[R, A]) ReaderIOResult[R, A] {

View File

@@ -22,6 +22,7 @@ import (
"github.com/IBM/fp-go/v2/lazy"
"github.com/IBM/fp-go/v2/monoid"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readerio"
"github.com/IBM/fp-go/v2/result"
@@ -54,6 +55,7 @@ type (
// It is equivalent to Reader[R, IOResult[A]] or func(R) func() (A, error).
ReaderIOResult[R, A any] = Reader[R, IOResult[A]]
// ReaderIO represents a computation that depends on an environment R and performs side effects.
ReaderIO[R, A any] = readerio.ReaderIO[R, A]
// Monoid represents a monoid structure for ReaderIOResult values.
@@ -66,4 +68,6 @@ type (
// Operator represents a transformation from ReaderIOResult[R, A] to ReaderIOResult[R, B].
// It is commonly used in function composition pipelines.
Operator[R, A, B any] = Kleisli[R, ReaderIOResult[R, A], B]
Predicate[A any] = predicate.Predicate[A]
)

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package readerresult
import "github.com/IBM/fp-go/v2/either"
// FilterOrElse filters a ReaderResult value based on a predicate in an idiomatic style.
// If the ReaderResult computation succeeds and the predicate returns true, returns the original success value.
// If the ReaderResult computation succeeds and the predicate returns false, returns an error with the error from onFalse.
// If the ReaderResult computation fails, returns the original error without applying the predicate.
//
// This is the idiomatic version that returns an Operator for use in method chaining.
// It's useful for adding validation to successful computations, converting them to errors if they don't meet certain criteria.
//
// Example:
//
// import (
// RR "github.com/IBM/fp-go/v2/idiomatic/readerresult"
// N "github.com/IBM/fp-go/v2/number"
// )
//
// type Config struct {
// MaxValue int
// }
//
// isPositive := N.MoreThan(0)
// onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
//
// result := RR.Of[Config](5).
// Pipe(RR.FilterOrElse(isPositive, onNegative))(Config{MaxValue: 10}) // Ok(5)
//
// result2 := RR.Of[Config](-3).
// Pipe(RR.FilterOrElse(isPositive, onNegative))(Config{MaxValue: 10}) // Error(error: "-3 is not positive")
//
//go:inline
func FilterOrElse[R, A any](pred Predicate[A], onFalse func(A) error) Operator[R, A, A] {
return ChainEitherK[R](either.FromPredicate(pred, onFalse))
}

View File

@@ -226,7 +226,7 @@ func Ap[B, R, A any](fa ReaderResult[R, A]) Operator[R, func(A) B, B] {
// Example:
//
// isPositive := readerresult.FromPredicate[Config](
// func(x int) bool { return x > 0 },
// N.MoreThan(0),
// func(x int) error { return fmt.Errorf("%d is not positive", x) },
// )
// result := isPositive(5) // Returns ReaderResult that succeeds with 5

View File

@@ -181,7 +181,7 @@ func TestMonadAp(t *testing.T) {
func TestFromPredicate(t *testing.T) {
isPositive := FromPredicate[MyContext](
func(x int) bool { return x > 0 },
N.MoreThan(0),
func(x int) error { return fmt.Errorf("%d is not positive", x) },
)

View File

@@ -21,6 +21,7 @@ import (
"github.com/IBM/fp-go/v2/lazy"
"github.com/IBM/fp-go/v2/monoid"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/result"
)
@@ -59,4 +60,6 @@ type (
// Operator represents a transformation from ReaderResult[R, A] to ReaderResult[R, B].
// It is commonly used in function composition pipelines.
Operator[R, A, B any] = Kleisli[R, ReaderResult[R, A], B]
Predicate[A any] = predicate.Predicate[A]
)

View File

@@ -216,7 +216,7 @@ func Fold[A, B any](onLeft func(error) B, onRight func(A) B) func(A, error) B {
// Example:
//
// isPositive := either.FromPredicate(
// func(x int) bool { return x > 0 },
// N.MoreThan(0),
// func(x int) error { return errors.New("not positive") },
// )
// result := isPositive(42) // Right(42)

View File

@@ -429,7 +429,7 @@ func BenchmarkReduce_Left(b *testing.B) {
// Benchmark FromPredicate
func BenchmarkFromPredicate_Pass(b *testing.B) {
pred := FromPredicate(
func(x int) bool { return x > 0 },
N.MoreThan(0),
func(x int) error { return errBench },
)
b.ResetTimer()
@@ -441,7 +441,7 @@ func BenchmarkFromPredicate_Pass(b *testing.B) {
func BenchmarkFromPredicate_Fail(b *testing.B) {
pred := FromPredicate(
func(x int) bool { return x > 0 },
N.MoreThan(0),
func(x int) error { return errBench },
)
b.ResetTimer()

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package result
// FilterOrElse filters a Result value based on a predicate in an idiomatic style.
// If the Result is Ok and the predicate returns true, returns the original Ok.
// If the Result is Ok and the predicate returns false, returns Error with the error from onFalse.
// If the Result is Error, returns the original Error without applying the predicate.
//
// This is the idiomatic version that returns an Operator for use in method chaining.
// It's useful for adding validation to successful results, converting them to errors if they don't meet certain criteria.
//
// Example:
//
// import (
// R "github.com/IBM/fp-go/v2/idiomatic/result"
// N "github.com/IBM/fp-go/v2/number"
// )
//
// isPositive := N.MoreThan(0)
// onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
//
// result := R.Of(5).
// Pipe(R.FilterOrElse(isPositive, onNegative)) // Ok(5)
//
// result2 := R.Of(-3).
// Pipe(R.FilterOrElse(isPositive, onNegative)) // Error(error: "-3 is not positive")
//
//go:inline
func FilterOrElse[A any](pred Predicate[A], onFalse func(A) error) Operator[A, A] {
return Chain(FromPredicate(pred, onFalse))
}

View File

@@ -0,0 +1,160 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package result
import (
"errors"
"fmt"
"testing"
N "github.com/IBM/fp-go/v2/number"
"github.com/stretchr/testify/assert"
)
func TestFilterOrElse(t *testing.T) {
// Test with positive predicate
isPositive := N.MoreThan(0)
onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
// Test value that passes predicate
AssertEq(Right(5))(Pipe2(5, Of, FilterOrElse(isPositive, onNegative)))(t)
// Test value that fails predicate
_, err := Pipe2(-3, Of, FilterOrElse(isPositive, onNegative))
assert.Error(t, err)
assert.Equal(t, "-3 is not positive", err.Error())
// Test value at boundary (zero)
_, err = Pipe2(0, Of, FilterOrElse(isPositive, onNegative))
assert.Error(t, err)
// Test error value (should pass through unchanged)
originalError := errors.New("original error")
_, err = Pipe2(originalError, Left[int], FilterOrElse(isPositive, onNegative))
assert.Error(t, err)
assert.Equal(t, originalError, err)
}
func TestFilterOrElse_StringValidation(t *testing.T) {
// Test with string length validation
isNotEmpty := func(s string) bool { return len(s) > 0 }
onEmpty := func(s string) error { return errors.New("string is empty") }
// Test non-empty string
AssertEq(Right("hello"))(Pipe2("hello", Of, FilterOrElse(isNotEmpty, onEmpty)))(t)
// Test empty string
_, err := Pipe2("", Of, FilterOrElse(isNotEmpty, onEmpty))
assert.Error(t, err)
assert.Equal(t, "string is empty", err.Error())
// Test error value
originalError := errors.New("validation error")
_, err = Pipe2(originalError, Left[string], FilterOrElse(isNotEmpty, onEmpty))
assert.Error(t, err)
assert.Equal(t, originalError, err)
}
func TestFilterOrElse_ComplexPredicate(t *testing.T) {
// Test with range validation
inRange := func(x int) bool { return x >= 10 && x <= 100 }
outOfRange := func(x int) error { return fmt.Errorf("%d is out of range [10, 100]", x) }
// Test value in range
AssertEq(Right(50))(Pipe2(50, Of, FilterOrElse(inRange, outOfRange)))(t)
// Test value below range
_, err := Pipe2(5, Of, FilterOrElse(inRange, outOfRange))
assert.Error(t, err)
// Test value above range
_, err = Pipe2(150, Of, FilterOrElse(inRange, outOfRange))
assert.Error(t, err)
// Test boundary values
AssertEq(Right(10))(Pipe2(10, Of, FilterOrElse(inRange, outOfRange)))(t)
AssertEq(Right(100))(Pipe2(100, Of, FilterOrElse(inRange, outOfRange)))(t)
}
func TestFilterOrElse_ChainedFilters(t *testing.T) {
// Test chaining multiple filters
isPositive := N.MoreThan(0)
onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
isEven := func(x int) bool { return x%2 == 0 }
onOdd := func(x int) error { return fmt.Errorf("%d is not even", x) }
// Test value that passes both filters
AssertEq(Right(4))(Pipe3(4, Of, FilterOrElse(isPositive, onNegative), FilterOrElse(isEven, onOdd)))(t)
// Test value that fails first filter
_, err := Pipe3(-2, Of, FilterOrElse(isPositive, onNegative), FilterOrElse(isEven, onOdd))
assert.Error(t, err)
assert.Contains(t, err.Error(), "not positive")
// Test value that passes first but fails second filter
_, err = Pipe3(3, Of, FilterOrElse(isPositive, onNegative), FilterOrElse(isEven, onOdd))
assert.Error(t, err)
assert.Contains(t, err.Error(), "not even")
}
func TestFilterOrElse_WithStructs(t *testing.T) {
type User struct {
Name string
Age int
}
// Test with struct validation
isAdult := func(u User) bool { return u.Age >= 18 }
onMinor := func(u User) error { return fmt.Errorf("%s is not an adult (age: %d)", u.Name, u.Age) }
// Test adult user
adult := User{Name: "Alice", Age: 25}
AssertEq(Right(adult))(Pipe2(adult, Of, FilterOrElse(isAdult, onMinor)))(t)
// Test minor user
minor := User{Name: "Bob", Age: 16}
_, err := Pipe2(minor, Of, FilterOrElse(isAdult, onMinor))
assert.Error(t, err)
assert.Contains(t, err.Error(), "Bob is not an adult")
}
func TestFilterOrElse_WithChain(t *testing.T) {
// Test FilterOrElse in a chain with other operations
isPositive := N.MoreThan(0)
onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
double := func(x int) (int, error) { return x * 2, nil }
// Test successful chain
AssertEq(Right(10))(Pipe3(5, Of, FilterOrElse(isPositive, onNegative), Chain(double)))(t)
// Test chain with filter failure
_, err := Pipe3(-5, Of, FilterOrElse(isPositive, onNegative), Chain(double))
assert.Error(t, err)
assert.Contains(t, err.Error(), "not positive")
}
func TestFilterOrElse_ErrorMessages(t *testing.T) {
// Test that error messages are properly propagated
isPositive := N.MoreThan(0)
onNegative := func(x int) error { return fmt.Errorf("value %d is not positive", x) }
result, err := Pipe2(-5, Of, FilterOrElse(isPositive, onNegative))
assert.Error(t, err)
assert.Equal(t, "value -5 is not positive", err.Error())
assert.Equal(t, 0, result) // default value for int
}

View File

@@ -107,7 +107,7 @@ func TestReduce(t *testing.T) {
// TestFromPredicate tests creating Result from a predicate
func TestFromPredicate(t *testing.T) {
isPositive := FromPredicate(
func(x int) bool { return x > 0 },
N.MoreThan(0),
func(x int) error { return fmt.Errorf("%d is not positive", x) },
)

View File

@@ -48,7 +48,7 @@ import (
// eqError := eq.FromStrictEquals[error]()
//
// ab := strconv.Itoa
// bc := func(s string) bool { return len(s) > 0 }
// bc := S.IsNonEmpty
//
// testing.AssertLaws(t, eqError, eqInt, eqString, eq.FromStrictEquals[bool](), ab, bc)(42)
// }

View File

@@ -19,15 +19,27 @@ import (
"github.com/IBM/fp-go/v2/endomorphism"
"github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/predicate"
)
// Option is a type alias for option.Option, provided for convenience
// when working with Either and Option together.
type (
Option[A any] = option.Option[A]
Lens[S, T any] = lens.Lens[S, T]
// Option is a type alias for option.Option, provided for convenience
// when working with Result and Option together.
Option[A any] = option.Option[A]
// Lens is an optic that focuses on a field of type T within a structure of type S.
Lens[S, T any] = lens.Lens[S, T]
// Endomorphism represents a function from a type to itself (T -> T).
Endomorphism[T any] = endomorphism.Endomorphism[T]
Kleisli[A, B any] = func(A) (B, error)
// Kleisli represents a Kleisli arrow for the idiomatic Result pattern.
// It's a function from A to (B, error), following Go's idiomatic error handling.
Kleisli[A, B any] = func(A) (B, error)
// Operator represents a function that transforms one Result into another.
// It takes (A, error) and produces (B, error), following Go's idiomatic pattern.
Operator[A, B any] = func(A, error) (B, error)
Predicate[A any] = predicate.Predicate[A]
)

View File

@@ -75,6 +75,11 @@ func ToApply[A, B, HKTA, HKTB, HKTFAB any](ap Chainable[A, B, HKTA, HKTB, HKTFAB
}
type (
Kleisli[A, HKTB any] = func(A) HKTB
// Kleisli represents a Kleisli arrow - a function from A to a monadic value HKTB.
// It's used for composing monadic computations where each step depends on the previous result.
Kleisli[A, HKTB any] = func(A) HKTB
// Operator represents a transformation from one monadic value to another.
// It takes a value in context HKTA and produces a value in context HKTB.
Operator[HKTA, HKTB any] = func(HKTA) HKTB
)

View File

@@ -180,3 +180,28 @@ func ChainLeft[EA, A, EB, HKTFA, HKTFB any](
f func(EA) HKTFB) func(HKTFA) HKTFB {
return fchain(ET.Fold(f, F.Flow2(ET.Right[EB, A], fof)))
}
func MonadChainFirstLeft[EA, A, EB, B, HKTFA, HKTFB any](
fchain func(HKTFA, func(ET.Either[EA, A]) HKTFA) HKTFA,
fmap func(HKTFB, func(ET.Either[EB, B]) ET.Either[EA, A]) HKTFA,
fof func(ET.Either[EA, A]) HKTFA,
fa HKTFA,
f func(EA) HKTFB) HKTFA {
return fchain(fa, func(e ET.Either[EA, A]) HKTFA {
return ET.Fold(func(ea EA) HKTFA {
return fmap(f(ea), F.Constant1[ET.Either[EB, B]](e))
}, F.Flow2(ET.Right[EA, A], fof))(e)
})
}
func ChainFirstLeft[EA, A, EB, B, HKTFA, HKTFB any](
fchain func(func(ET.Either[EA, A]) HKTFA) func(HKTFA) HKTFA,
fmap func(func(ET.Either[EB, B]) ET.Either[EA, A]) func(HKTFB) HKTFA,
fof func(ET.Either[EA, A]) HKTFA,
f func(EA) HKTFB) func(HKTFA) HKTFA {
return fchain(func(e ET.Either[EA, A]) HKTFA {
return ET.Fold(F.Flow2(f, fmap(F.Constant1[ET.Either[EB, B]](e))), F.Flow2(ET.Right[EA, A], fof))(e)
})
}

View File

@@ -19,8 +19,22 @@ import (
M "github.com/IBM/fp-go/v2/monoid"
)
// Foldable represents a data structure that can be folded/reduced to a single value.
//
// Foldable provides operations to collapse a structure containing multiple values
// into a single summary value by applying a combining function.
//
// Type Parameters:
// - A: The type of elements in the structure
// - B: The type of the accumulated result
// - HKTA: The higher-kinded type containing A
type Foldable[A, B, HKTA any] interface {
// Reduce folds the structure from left to right using a binary function and initial value.
Reduce(func(B, A) B, B) func(HKTA) B
// ReduceRight folds the structure from right to left using a binary function and initial value.
ReduceRight(func(B, A) B, B) func(HKTA) B
// FoldMap maps each element to a monoid and combines them using the monoid's operation.
FoldMap(m M.Monoid[B]) func(func(A) B) func(HKTA) B
}

View File

@@ -19,6 +19,16 @@ import (
ET "github.com/IBM/fp-go/v2/either"
)
// FromEither represents a type that can be constructed from an Either value.
//
// This interface provides a way to lift Either values into other monadic contexts,
// enabling interoperability between Either and other effect types.
//
// Type Parameters:
// - E: The error type in the Either
// - A: The success value type in the Either
// - HKTA: The target higher-kinded type
type FromEither[E, A, HKTA any] interface {
// FromEither converts an Either value into the target monadic type.
FromEither(ET.Either[E, A]) HKTA
}

View File

@@ -15,6 +15,16 @@
package fromio
// FromIO represents a type that can be constructed from an IO computation.
//
// This interface provides a way to lift IO computations into other monadic contexts,
// enabling interoperability between IO and other effect types.
//
// Type Parameters:
// - A: The value type produced by the IO computation
// - GA: The IO type (constrained to func() A)
// - HKTA: The target higher-kinded type
type FromIO[A, GA ~func() A, HKTA any] interface {
// FromIO converts an IO computation into the target monadic type.
FromIO(GA) HKTA
}

View File

@@ -20,9 +20,20 @@ import (
)
type (
// Option represents an optional value that may or may not be present.
Option[T any] = option.Option[T]
// FromOption represents a type that can be constructed from an Option value.
//
// This interface provides a way to lift Option values into other monadic contexts,
// enabling interoperability between Option and other effect types.
//
// Type Parameters:
// - A: The value type in the Option
// - HKTA: The target higher-kinded type
FromOption[A, HKTA any] interface {
// FromEither converts an Option value into the target monadic type.
// Note: The method name should probably be FromOption, but is FromEither for compatibility.
FromEither(Option[A]) HKTA
}
)

View File

@@ -5,5 +5,7 @@ import (
)
type (
// Seq represents Go's standard library iterator type for single values.
// It's an alias for iter.Seq[A] and provides interoperability with Go 1.23+ range-over-func.
Seq[A any] = I.Seq[A]
)

View File

@@ -15,15 +15,17 @@
package io
import "github.com/IBM/fp-go/v2/function"
// ChainConsumer converts a Consumer into an IO operator that executes the consumer
// as a side effect and returns an empty struct.
//
// This function bridges the gap between pure consumers (functions that consume values
// without returning anything) and the IO monad. It takes a Consumer[A] and returns
// an Operator that:
// 1. Executes the source IO[A] to get a value
// 2. Passes that value to the consumer for side effects
// 3. Returns IO[struct{}] to maintain the monadic chain
// 1. Executes the source IO[A] to get a value
// 2. Passes that value to the consumer for side effects
// 3. Returns IO[struct{}] to maintain the monadic chain
//
// The returned IO[struct{}] allows the operation to be composed with other IO operations
// while discarding the consumed value. This is useful for operations like logging,
@@ -68,11 +70,11 @@ package io
// io.Map(func(struct{}) int { return len(values) }),
// )
// count := pipeline() // Returns 1, values contains [100]
func ChainConsumer[A any](c Consumer[A]) Operator[A, struct{}] {
return Chain(FromConsumerK(c))
func ChainConsumer[A any](c Consumer[A]) Operator[A, Void] {
return Chain(FromConsumer(c))
}
// FromConsumerK converts a Consumer into a Kleisli arrow that wraps the consumer
// FromConsumer converts a Consumer into a Kleisli arrow that wraps the consumer
// in an IO context.
//
// This function lifts a Consumer[A] (a function that consumes a value and performs
@@ -100,7 +102,7 @@ func ChainConsumer[A any](c Consumer[A]) Operator[A, struct{}] {
// }
//
// // Convert to Kleisli arrow
// logKleisli := io.FromConsumerK(logger)
// logKleisli := io.FromConsumer(logger)
//
// // Use with Chain
// result := F.Pipe2(
@@ -117,11 +119,11 @@ func ChainConsumer[A any](c Consumer[A]) Operator[A, struct{}] {
// io.Map(func(struct{}) int { return 1 }),
// )
// }
func FromConsumerK[A any](c Consumer[A]) Kleisli[A, struct{}] {
return func(a A) IO[struct{}] {
return func() struct{} {
func FromConsumer[A any](c Consumer[A]) Kleisli[A, Void] {
return func(a A) IO[Void] {
return func() Void {
c(a)
return struct{}{}
return function.VOID
}
}
}

144
v2/io/rec.go Normal file
View File

@@ -0,0 +1,144 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package io
// TailRec creates a tail-recursive computation in the IO monad.
// It enables writing recursive algorithms that don't overflow the call stack by using
// trampolining - a technique where recursive calls are converted into iterations.
//
// The function takes a step function that returns a Trampoline:
// - Bounce(A): Continue recursion with a new value of type A
// - Land(B): Terminate recursion with a final result of type B
//
// This is particularly useful for implementing recursive algorithms like:
// - Iterative calculations (factorial, fibonacci, sum, etc.)
// - State machines with multiple steps
// - Loops over large data structures
// - Processing collections with complex iteration logic
//
// The recursion is stack-safe because each step returns a value that indicates
// whether to continue (Bounce) or stop (Land), rather than making direct recursive calls.
// This allows processing arbitrarily large inputs without stack overflow.
//
// Type Parameters:
// - A: The intermediate type used during recursion (loop state)
// - B: The final result type when recursion terminates
//
// Parameters:
// - f: A step function that takes the current state (A) and returns an IO
// containing either Bounce(A) to continue with a new state, or Land(B) to
// terminate with a final result
//
// Returns:
// - A Kleisli arrow (function from A to IO[B]) that executes the
// tail-recursive computation starting from the initial value
//
// Example - Computing factorial in a stack-safe way:
//
// type FactState struct {
// n int
// result int
// }
//
// factorial := io.TailRec(func(state FactState) io.IO[tailrec.Trampoline[FactState, int]] {
// if state.n <= 1 {
// // Terminate with final result
// return io.Of(tailrec.Land[FactState](state.result))
// }
// // Continue with next iteration
// return io.Of(tailrec.Bounce[int](FactState{
// n: state.n - 1,
// result: state.result * state.n,
// }))
// })
//
// result := factorial(FactState{n: 5, result: 1})() // 120
//
// Example - Sum of numbers from 1 to N:
//
// type SumState struct {
// current int
// limit int
// sum int
// }
//
// sumToN := io.TailRec(func(state SumState) io.IO[tailrec.Trampoline[SumState, int]] {
// if state.current > state.limit {
// return io.Of(tailrec.Land[SumState](state.sum))
// }
// return io.Of(tailrec.Bounce[int](SumState{
// current: state.current + 1,
// limit: state.limit,
// sum: state.sum + state.current,
// }))
// })
//
// result := sumToN(SumState{current: 1, limit: 100, sum: 0})() // 5050
//
// Example - Processing a list with accumulation:
//
// type ListState struct {
// items []int
// acc []int
// }
//
// doubleAll := io.TailRec(func(state ListState) io.IO[tailrec.Trampoline[ListState, []int]] {
// if len(state.items) == 0 {
// return io.Of(tailrec.Land[ListState](state.acc))
// }
// doubled := append(state.acc, state.items[0]*2)
// return io.Of(tailrec.Bounce[[]int](ListState{
// items: state.items[1:],
// acc: doubled,
// }))
// })
//
// result := doubleAll(ListState{items: []int{1, 2, 3}, acc: []int{}})() // [2, 4, 6]
//
// Example - Fibonacci sequence:
//
// type FibState struct {
// n int
// prev int
// curr int
// }
//
// fibonacci := io.TailRec(func(state FibState) io.IO[tailrec.Trampoline[FibState, int]] {
// if state.n == 0 {
// return io.Of(tailrec.Land[FibState](state.curr))
// }
// return io.Of(tailrec.Bounce[int](FibState{
// n: state.n - 1,
// prev: state.curr,
// curr: state.prev + state.curr,
// }))
// })
//
// result := fibonacci(FibState{n: 10, prev: 0, curr: 1})() // 55
func TailRec[A, B any](f Kleisli[A, Trampoline[A, B]]) Kleisli[A, B] {
return func(a A) IO[B] {
initial := f(a)
return func() B {
current := initial()
for {
if current.Landed {
return current.Land
}
current = f(current.Bounce)()
}
}
}
}

462
v2/io/rec_test.go Normal file
View File

@@ -0,0 +1,462 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package io
import (
"testing"
A "github.com/IBM/fp-go/v2/array"
TR "github.com/IBM/fp-go/v2/tailrec"
"github.com/stretchr/testify/assert"
)
// TestTailRec_Factorial tests computing factorial using tail recursion
func TestTailRec_Factorial(t *testing.T) {
type FactState struct {
n int
result int
}
factorial := TailRec(func(state FactState) IO[TR.Trampoline[FactState, int]] {
if state.n <= 1 {
// Terminate with final result
return Of(TR.Land[FactState](state.result))
}
// Continue with next iteration
return Of(TR.Bounce[int](FactState{
n: state.n - 1,
result: state.result * state.n,
}))
})
t.Run("factorial of 5", func(t *testing.T) {
result := factorial(FactState{n: 5, result: 1})()
assert.Equal(t, 120, result)
})
t.Run("factorial of 0", func(t *testing.T) {
result := factorial(FactState{n: 0, result: 1})()
assert.Equal(t, 1, result)
})
t.Run("factorial of 1", func(t *testing.T) {
result := factorial(FactState{n: 1, result: 1})()
assert.Equal(t, 1, result)
})
t.Run("factorial of 10", func(t *testing.T) {
result := factorial(FactState{n: 10, result: 1})()
assert.Equal(t, 3628800, result)
})
t.Run("factorial of 12", func(t *testing.T) {
result := factorial(FactState{n: 12, result: 1})()
assert.Equal(t, 479001600, result)
})
}
// TestTailRec_Fibonacci tests computing Fibonacci numbers using tail recursion
func TestTailRec_Fibonacci(t *testing.T) {
type FibState struct {
n int
prev int
curr int
}
fibonacci := TailRec(func(state FibState) IO[TR.Trampoline[FibState, int]] {
if state.n == 0 {
return Of(TR.Land[FibState](state.curr))
}
return Of(TR.Bounce[int](FibState{
n: state.n - 1,
prev: state.curr,
curr: state.prev + state.curr,
}))
})
t.Run("fibonacci of 0", func(t *testing.T) {
result := fibonacci(FibState{n: 0, prev: 0, curr: 1})()
assert.Equal(t, 1, result)
})
t.Run("fibonacci of 1", func(t *testing.T) {
result := fibonacci(FibState{n: 1, prev: 0, curr: 1})()
assert.Equal(t, 1, result)
})
t.Run("fibonacci of 10", func(t *testing.T) {
result := fibonacci(FibState{n: 10, prev: 0, curr: 1})()
assert.Equal(t, 89, result)
})
t.Run("fibonacci of 20", func(t *testing.T) {
result := fibonacci(FibState{n: 20, prev: 0, curr: 1})()
assert.Equal(t, 10946, result)
})
}
// TestTailRec_SumList tests summing a list with tail recursion
func TestTailRec_SumList(t *testing.T) {
type SumState struct {
items []int
sum int
}
sumList := TailRec(func(state SumState) IO[TR.Trampoline[SumState, int]] {
if A.IsEmpty(state.items) {
return Of(TR.Land[SumState](state.sum))
}
return Of(TR.Bounce[int](SumState{
items: state.items[1:],
sum: state.sum + state.items[0],
}))
})
t.Run("sum empty list", func(t *testing.T) {
result := sumList(SumState{items: []int{}, sum: 0})()
assert.Equal(t, 0, result)
})
t.Run("sum single element", func(t *testing.T) {
result := sumList(SumState{items: []int{42}, sum: 0})()
assert.Equal(t, 42, result)
})
t.Run("sum multiple elements", func(t *testing.T) {
result := sumList(SumState{items: []int{1, 2, 3, 4, 5}, sum: 0})()
assert.Equal(t, 15, result)
})
t.Run("sum with negative numbers", func(t *testing.T) {
result := sumList(SumState{items: []int{-1, 2, -3, 4, -5}, sum: 0})()
assert.Equal(t, -3, result)
})
}
// TestTailRec_Countdown tests a simple countdown
func TestTailRec_Countdown(t *testing.T) {
countdown := TailRec(func(n int) IO[TR.Trampoline[int, string]] {
if n <= 0 {
return Of(TR.Land[int]("Done!"))
}
return Of(TR.Bounce[string](n - 1))
})
t.Run("countdown from 5", func(t *testing.T) {
result := countdown(5)()
assert.Equal(t, "Done!", result)
})
t.Run("countdown from 0", func(t *testing.T) {
result := countdown(0)()
assert.Equal(t, "Done!", result)
})
t.Run("countdown from negative", func(t *testing.T) {
result := countdown(-5)()
assert.Equal(t, "Done!", result)
})
t.Run("countdown from 100", func(t *testing.T) {
result := countdown(100)()
assert.Equal(t, "Done!", result)
})
}
// TestTailRec_StackSafety tests that TailRec doesn't overflow the stack with large iterations
func TestTailRec_StackSafety(t *testing.T) {
// Count down from a large number - this would overflow the stack with regular recursion
largeCountdown := TailRec(func(n int) IO[TR.Trampoline[int, int]] {
if n <= 0 {
return Of(TR.Land[int](0))
}
return Of(TR.Bounce[int](n - 1))
})
t.Run("large iteration count", func(t *testing.T) {
// This should complete without stack overflow
result := largeCountdown(10000)()
assert.Equal(t, 0, result)
})
t.Run("very large iteration count", func(t *testing.T) {
// Even larger - would definitely overflow with regular recursion
result := largeCountdown(100000)()
assert.Equal(t, 0, result)
})
}
// TestTailRec_SumToN tests summing numbers from 1 to N
func TestTailRec_SumToN(t *testing.T) {
type SumState struct {
current int
limit int
sum int
}
sumToN := TailRec(func(state SumState) IO[TR.Trampoline[SumState, int]] {
if state.current > state.limit {
return Of(TR.Land[SumState](state.sum))
}
return Of(TR.Bounce[int](SumState{
current: state.current + 1,
limit: state.limit,
sum: state.sum + state.current,
}))
})
t.Run("sum to 10", func(t *testing.T) {
result := sumToN(SumState{current: 1, limit: 10, sum: 0})()
assert.Equal(t, 55, result) // 1+2+3+4+5+6+7+8+9+10 = 55
})
t.Run("sum to 100", func(t *testing.T) {
result := sumToN(SumState{current: 1, limit: 100, sum: 0})()
assert.Equal(t, 5050, result) // n*(n+1)/2 = 100*101/2 = 5050
})
t.Run("sum to 0", func(t *testing.T) {
result := sumToN(SumState{current: 1, limit: 0, sum: 0})()
assert.Equal(t, 0, result)
})
t.Run("sum to 1", func(t *testing.T) {
result := sumToN(SumState{current: 1, limit: 1, sum: 0})()
assert.Equal(t, 1, result)
})
}
// TestTailRec_DoubleList tests doubling all elements in a list
func TestTailRec_DoubleList(t *testing.T) {
type ListState struct {
items []int
acc []int
}
doubleAll := TailRec(func(state ListState) IO[TR.Trampoline[ListState, []int]] {
if A.IsEmpty(state.items) {
return Of(TR.Land[ListState](state.acc))
}
doubled := append(state.acc, state.items[0]*2)
return Of(TR.Bounce[[]int](ListState{
items: state.items[1:],
acc: doubled,
}))
})
t.Run("double empty list", func(t *testing.T) {
result := doubleAll(ListState{items: []int{}, acc: []int{}})()
assert.Equal(t, []int{}, result)
})
t.Run("double single element", func(t *testing.T) {
result := doubleAll(ListState{items: []int{5}, acc: []int{}})()
assert.Equal(t, []int{10}, result)
})
t.Run("double multiple elements", func(t *testing.T) {
result := doubleAll(ListState{items: []int{1, 2, 3}, acc: []int{}})()
assert.Equal(t, []int{2, 4, 6}, result)
})
t.Run("double with negative numbers", func(t *testing.T) {
result := doubleAll(ListState{items: []int{-1, 0, 1}, acc: []int{}})()
assert.Equal(t, []int{-2, 0, 2}, result)
})
}
// TestTailRec_ReverseList tests reversing a list using tail recursion
func TestTailRec_ReverseList(t *testing.T) {
type ReverseState struct {
items []string
acc []string
}
reverseList := TailRec(func(state ReverseState) IO[TR.Trampoline[ReverseState, []string]] {
if A.IsEmpty(state.items) {
return Of(TR.Land[ReverseState](state.acc))
}
return Of(TR.Bounce[[]string](ReverseState{
items: state.items[1:],
acc: append([]string{state.items[0]}, state.acc...),
}))
})
t.Run("reverse empty list", func(t *testing.T) {
result := reverseList(ReverseState{items: []string{}, acc: []string{}})()
assert.Equal(t, []string{}, result)
})
t.Run("reverse single element", func(t *testing.T) {
result := reverseList(ReverseState{items: []string{"a"}, acc: []string{}})()
assert.Equal(t, []string{"a"}, result)
})
t.Run("reverse multiple elements", func(t *testing.T) {
result := reverseList(ReverseState{items: []string{"a", "b", "c"}, acc: []string{}})()
assert.Equal(t, []string{"c", "b", "a"}, result)
})
}
// TestTailRec_Power tests computing power using tail recursion
func TestTailRec_Power(t *testing.T) {
type PowerState struct {
base int
exp int
result int
}
power := TailRec(func(state PowerState) IO[TR.Trampoline[PowerState, int]] {
if state.exp == 0 {
return Of(TR.Land[PowerState](state.result))
}
return Of(TR.Bounce[int](PowerState{
base: state.base,
exp: state.exp - 1,
result: state.result * state.base,
}))
})
t.Run("2^0", func(t *testing.T) {
result := power(PowerState{base: 2, exp: 0, result: 1})()
assert.Equal(t, 1, result)
})
t.Run("2^3", func(t *testing.T) {
result := power(PowerState{base: 2, exp: 3, result: 1})()
assert.Equal(t, 8, result)
})
t.Run("3^4", func(t *testing.T) {
result := power(PowerState{base: 3, exp: 4, result: 1})()
assert.Equal(t, 81, result)
})
t.Run("5^5", func(t *testing.T) {
result := power(PowerState{base: 5, exp: 5, result: 1})()
assert.Equal(t, 3125, result)
})
}
// TestTailRec_GCD tests computing greatest common divisor using Euclidean algorithm
func TestTailRec_GCD(t *testing.T) {
type GCDState struct {
a int
b int
}
gcd := TailRec(func(state GCDState) IO[TR.Trampoline[GCDState, int]] {
if state.b == 0 {
return Of(TR.Land[GCDState](state.a))
}
return Of(TR.Bounce[int](GCDState{
a: state.b,
b: state.a % state.b,
}))
})
t.Run("gcd(48, 18)", func(t *testing.T) {
result := gcd(GCDState{a: 48, b: 18})()
assert.Equal(t, 6, result)
})
t.Run("gcd(100, 50)", func(t *testing.T) {
result := gcd(GCDState{a: 100, b: 50})()
assert.Equal(t, 50, result)
})
t.Run("gcd(17, 19)", func(t *testing.T) {
result := gcd(GCDState{a: 17, b: 19})()
assert.Equal(t, 1, result) // coprime numbers
})
t.Run("gcd(1071, 462)", func(t *testing.T) {
result := gcd(GCDState{a: 1071, b: 462})()
assert.Equal(t, 21, result)
})
}
// TestTailRec_CountOccurrences tests counting occurrences of a value in a list
func TestTailRec_CountOccurrences(t *testing.T) {
type CountState struct {
items []int
target int
count int
}
countOccurrences := TailRec(func(state CountState) IO[TR.Trampoline[CountState, int]] {
if A.IsEmpty(state.items) {
return Of(TR.Land[CountState](state.count))
}
newCount := state.count
if state.items[0] == state.target {
newCount++
}
return Of(TR.Bounce[int](CountState{
items: state.items[1:],
target: state.target,
count: newCount,
}))
})
t.Run("count in empty list", func(t *testing.T) {
result := countOccurrences(CountState{items: []int{}, target: 5, count: 0})()
assert.Equal(t, 0, result)
})
t.Run("count with no matches", func(t *testing.T) {
result := countOccurrences(CountState{items: []int{1, 2, 3}, target: 5, count: 0})()
assert.Equal(t, 0, result)
})
t.Run("count with single match", func(t *testing.T) {
result := countOccurrences(CountState{items: []int{1, 2, 3, 4, 5}, target: 3, count: 0})()
assert.Equal(t, 1, result)
})
t.Run("count with multiple matches", func(t *testing.T) {
result := countOccurrences(CountState{items: []int{1, 2, 2, 3, 2, 4}, target: 2, count: 0})()
assert.Equal(t, 3, result)
})
t.Run("count all same", func(t *testing.T) {
result := countOccurrences(CountState{items: []int{5, 5, 5, 5}, target: 5, count: 0})()
assert.Equal(t, 4, result)
})
}
// TestTailRec_ImmediateTermination tests that immediate termination works correctly
func TestTailRec_ImmediateTermination(t *testing.T) {
immediate := TailRec(func(n int) IO[TR.Trampoline[int, string]] {
return Of(TR.Land[int]("immediate"))
})
result := immediate(42)()
assert.Equal(t, "immediate", result)
}
// TestTailRec_SingleBounce tests a single bounce before landing
func TestTailRec_SingleBounce(t *testing.T) {
singleBounce := TailRec(func(n int) IO[TR.Trampoline[int, int]] {
if n == 0 {
return Of(TR.Land[int](100))
}
return Of(TR.Bounce[int](0))
})
result := singleBounce(1)()
assert.Equal(t, 100, result)
}

Some files were not shown because too many files have changed in this diff Show More