Skip to content

Commit f99c9ae

Browse files
Leo Antunescostela
authored andcommitted
feat!: move the type parameters to a separate wrapper
This makes the circuit itself type-agnostic, allowing reuse of circuits for multiple functions with different types. BREAKING CHANGE: circuit initialization now does not require the function and does not provide the Call() method anymore, instead relying on the package-level Wrap().
1 parent a9b2ad7 commit f99c9ae

File tree

5 files changed

+117
-114
lines changed

5 files changed

+117
-114
lines changed

README.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,32 @@ Simple low-overhead circuit breaker library.
99
## Usage
1010

1111
```go
12+
// some arbitrary function
13+
foo := func(ctx context.Context, bar int) (Foo, error) {
14+
if bar == 42 {
15+
return Foo{Bar: bar}, nil
16+
}
17+
return Foo{}, fmt.Errorf("bar is not 42")
18+
}
19+
1220
h, err := hoglet.NewCircuit(
13-
func(ctx context.Context, bar int) (Foo, error) {
14-
if bar == 42 {
15-
return Foo{Bar: bar}, nil
16-
}
17-
return Foo{}, fmt.Errorf("bar is not 42")
18-
},
1921
hoglet.NewSlidingWindowBreaker(5*time.Second, 0.1),
2022
hoglet.WithFailureCondition(hoglet.IgnoreContextCanceled),
2123
)
2224
/* if err != nil ... */
2325

24-
f, _ := h.Call(context.Background(), 42)
26+
f, _ := hoglet.Wrap(h, foo)(context.Background(), 42)
2527
fmt.Println(f.Bar) // 42
2628

27-
_, err = h.Call(context.Background(), 0)
29+
_, err = hoglet.Wrap(h, foo)(context.Background(), 0)
2830
fmt.Println(err) // bar is not 42
2931

30-
_, err = h.Call(context.Background(), 42)
32+
_, err = hoglet.Wrap(h, foo)(context.Background(), 42)
3133
fmt.Println(err) // hoglet: breaker is open
3234

3335
time.Sleep(5 * time.Second)
3436

35-
f, _ = h.Call(context.Background(), 42)
37+
f, _ = hoglet.Wrap(h, foo)(context.Background(), 42)
3638
fmt.Println(f.Bar) // 42
3739
```
3840

@@ -51,4 +53,4 @@ non-racy behavior around the failed function.
5153
## Design
5254

5355
Hoglet prefers throughput to correctness (e.g. by avoiding locks), which means it cannot guarantee an exact number of
54-
calls will go through.
56+
calls will go through.

example_test.go

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,29 +22,28 @@ func foo(ctx context.Context, bar int) (Foo, error) {
2222

2323
func ExampleEWMABreaker() {
2424
h, err := hoglet.NewCircuit(
25-
foo,
2625
hoglet.NewEWMABreaker(10, 0.1),
2726
hoglet.WithHalfOpenDelay(time.Second),
2827
)
2928
if err != nil {
3029
log.Fatal(err)
3130
}
3231

33-
f, err := h.Call(context.Background(), 1)
32+
f, err := hoglet.Wrap(h, foo)(context.Background(), 1)
3433
if err != nil {
3534
log.Fatal(err)
3635
}
3736
fmt.Println(f.Bar)
3837

39-
_, err = h.Call(context.Background(), 100)
38+
_, err = hoglet.Wrap(h, foo)(context.Background(), 100)
4039
fmt.Println(err)
4140

42-
_, err = h.Call(context.Background(), 2)
41+
_, err = hoglet.Wrap(h, foo)(context.Background(), 2)
4342
fmt.Println(err)
4443

4544
time.Sleep(time.Second) // wait for half-open delay
4645

47-
f, err = h.Call(context.Background(), 3)
46+
f, err = hoglet.Wrap(h, foo)(context.Background(), 3)
4847
if err != nil {
4948
log.Fatal(err)
5049
}
@@ -59,28 +58,27 @@ func ExampleEWMABreaker() {
5958

6059
func ExampleSlidingWindowBreaker() {
6160
h, err := hoglet.NewCircuit(
62-
foo,
6361
hoglet.NewSlidingWindowBreaker(time.Second, 0.1),
6462
)
6563
if err != nil {
6664
log.Fatal(err)
6765
}
6866

69-
f, err := h.Call(context.Background(), 1)
67+
f, err := hoglet.Wrap(h, foo)(context.Background(), 1)
7068
if err != nil {
7169
log.Fatal(err)
7270
}
7371
fmt.Println(f.Bar)
7472

75-
_, err = h.Call(context.Background(), 100)
73+
_, err = hoglet.Wrap(h, foo)(context.Background(), 100)
7674
fmt.Println(err)
7775

78-
_, err = h.Call(context.Background(), 2)
76+
_, err = hoglet.Wrap(h, foo)(context.Background(), 2)
7977
fmt.Println(err)
8078

8179
time.Sleep(time.Second) // wait for sliding window
8280

83-
f, err = h.Call(context.Background(), 3)
81+
f, err = hoglet.Wrap(h, foo)(context.Background(), 3)
8482
if err != nil {
8583
log.Fatal(err)
8684
}
@@ -94,14 +92,15 @@ func ExampleSlidingWindowBreaker() {
9492
}
9593

9694
func ExampleConcurrencyLimiter() {
95+
foo := func(ctx context.Context, _ any) (any, error) {
96+
select {
97+
case <-ctx.Done():
98+
case <-time.After(time.Second):
99+
}
100+
return nil, nil
101+
}
102+
97103
h, err := hoglet.NewCircuit(
98-
func(ctx context.Context, _ any) (any, error) {
99-
select {
100-
case <-ctx.Done():
101-
case <-time.After(time.Second):
102-
}
103-
return nil, nil
104-
},
105104
hoglet.NewSlidingWindowBreaker(10, 0.1),
106105
hoglet.WithBreakerMiddleware(hoglet.ConcurrencyLimiter(1, false)),
107106
)
@@ -116,15 +115,15 @@ func ExampleConcurrencyLimiter() {
116115

117116
go func() {
118117
// use up the concurrency limit
119-
_, _ = h.Call(ctx, 42)
118+
_, _ = hoglet.Wrap(h, foo)(ctx, 42)
120119
}()
121120

122121
// ensure call above actually started
123122
time.Sleep(time.Millisecond * 100)
124123

125124
go func() {
126125
defer close(errCh)
127-
_, err := h.Call(ctx, 42)
126+
_, err := hoglet.Wrap(h, foo)(ctx, 42)
128127
if err != nil {
129128
errCh <- err
130129
}

hoglet.go

Lines changed: 52 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ import (
1313
// stops calling the wrapped function until it closes again, returning [ErrCircuitOpen] in the meantime.
1414
//
1515
// A zero Circuit will panic, analogous to calling a nil function variable. Initialize with [NewCircuit].
16-
type Circuit[IN, OUT any] struct {
17-
f WrappedFunc[IN, OUT]
16+
type Circuit struct {
1817
options
1918

2019
// State
@@ -64,8 +63,8 @@ func (f BreakerMiddlewareFunc) Wrap(of ObserverFactory) (ObserverFactory, error)
6463
return f(of)
6564
}
6665

67-
// WrappedFunc is the type of the function wrapped by a Breaker.
68-
type WrappedFunc[IN, OUT any] func(context.Context, IN) (OUT, error)
66+
// WrappableFunc is the type of the function wrapped by a [Circuit].
67+
type WrappableFunc[IN, OUT any] func(context.Context, IN) (OUT, error)
6968

7069
// dedupObservableCall wraps an [Observer] ensuring it can only be observed a single time.
7170
func dedupObservableCall(obs Observer) Observer {
@@ -83,12 +82,10 @@ func (d *dedupedObserver) Observe(failure bool) {
8382
})
8483
}
8584

86-
// NewCircuit instantiates a new [Circuit] that wraps the provided function. See [Circuit.Call] for calling semantics.
87-
// A Circuit with a nil breaker is a noop wrapper around the provided function and will never open.
88-
func NewCircuit[IN, OUT any](f WrappedFunc[IN, OUT], breaker Breaker, opts ...Option) (*Circuit[IN, OUT], error) {
89-
c := &Circuit[IN, OUT]{
90-
f: f,
91-
}
85+
// NewCircuit instantiates a new [Circuit]. See [Wrap] for further usage.
86+
// A [Circuit] with a nil breaker is a noop and will never open for any of its wrapped functions.
87+
func NewCircuit(breaker Breaker, opts ...Option) (*Circuit, error) {
88+
c := &Circuit{}
9289

9390
o := options{
9491
isFailure: defaultFailureCondition,
@@ -115,10 +112,10 @@ func NewCircuit[IN, OUT any](f WrappedFunc[IN, OUT], breaker Breaker, opts ...Op
115112
return c, nil
116113
}
117114

118-
// State reports the current [State] of the circuit.
115+
// State reports the current [State] of the [Circuit].
119116
// It should only be used for informational purposes. To minimize race conditions, the circuit should be called directly
120117
// instead of checking its state first.
121-
func (c *Circuit[IN, OUT]) State() State {
118+
func (c *Circuit) State() State {
122119
oa := c.openedAt.Load()
123120

124121
if oa == 0 {
@@ -137,7 +134,7 @@ func (c *Circuit[IN, OUT]) State() State {
137134

138135
// stateForCall returns the state of the circuit meant for the next call.
139136
// It wraps [State] to keep the mutable part outside of the external API.
140-
func (c *Circuit[IN, OUT]) stateForCall() State {
137+
func (c *Circuit) stateForCall() State {
141138
state := c.State()
142139

143140
if state == StateHalfOpen {
@@ -152,18 +149,18 @@ func (c *Circuit[IN, OUT]) stateForCall() State {
152149

153150
// open marks the circuit as open, if it not already.
154151
// It is safe for concurrent calls and only the first one will actually set opening time.
155-
func (c *Circuit[IN, OUT]) open() {
152+
func (c *Circuit) open() {
156153
// CompareAndSwap is needed to avoid clobbering another goroutine's openedAt value.
157154
c.openedAt.CompareAndSwap(0, time.Now().UnixMicro())
158155
}
159156

160157
// reopen forcefully (re)marks the circuit as open, resetting the half-open time.
161-
func (c *Circuit[IN, OUT]) reopen() {
158+
func (c *Circuit) reopen() {
162159
c.openedAt.Store(time.Now().UnixMicro())
163160
}
164161

165162
// close closes the circuit.
166-
func (c *Circuit[IN, OUT]) close() {
163+
func (c *Circuit) close() {
167164
c.openedAt.Store(0)
168165
}
169166

@@ -173,22 +170,22 @@ func (c *Circuit[IN, OUT]) close() {
173170
// If the breaker is closed, it returns a non-nil [Observer] that will be used to observe the result of the call.
174171
//
175172
// It implements [ObserverFactory], so that the [Circuit] can act as the base for [BreakerMiddleware].
176-
func (c *Circuit[IN, OUT]) ObserverForCall(_ context.Context, state State) (Observer, error) {
173+
func (c *Circuit) ObserverForCall(_ context.Context, state State) (Observer, error) {
177174
if state == StateOpen {
178175
return nil, ErrCircuitOpen
179176
}
180-
return stateObserver[IN, OUT]{
177+
return stateObserver{
181178
circuit: c,
182179
state: state,
183180
}, nil
184181
}
185182

186-
type stateObserver[IN, OUT any] struct {
187-
circuit *Circuit[IN, OUT]
183+
type stateObserver struct {
184+
circuit *Circuit
188185
state State
189186
}
190187

191-
func (s stateObserver[IN, OUT]) Observe(failure bool) {
188+
func (s stateObserver) Observe(failure bool) {
192189
switch s.circuit.breaker.observe(s.state == StateHalfOpen, failure) {
193190
case stateChangeNone:
194191
return // noop
@@ -199,59 +196,59 @@ func (s stateObserver[IN, OUT]) Observe(failure bool) {
199196
}
200197
}
201198

202-
// Call calls the wrapped function if the circuit is closed and returns its result. If the circuit is open, it returns
203-
// [ErrCircuitOpen].
199+
// Wrap wraps the provided function with the given [Circuit].
200+
//
201+
// Calling the returned function if the circuit is closed and returns the result of the wrapped function.
202+
// If the circuit is open, it returns [ErrCircuitOpen].
204203
//
205204
// The wrapped function is called synchronously, but possible context errors are recorded as soon as they occur. This
206205
// ensures the circuit opens quickly, even if the wrapped function blocks.
207206
//
208207
// By default, all errors are considered failures (including [context.Canceled]), but this can be customized via
209-
// [WithFailureCondition] and [IgnoreContextCanceled].
208+
// [WithFailureCondition] and [IgnoreContextCanceled] on the provided [Circuit].
210209
//
211210
// Panics are observed as failures, but are not recovered (i.e.: they are "repanicked" instead).
212-
func (c *Circuit[IN, OUT]) Call(ctx context.Context, in IN) (out OUT, err error) {
213-
if c.f == nil {
214-
return out, nil
215-
}
216-
217-
obs, err := c.observerFactory.ObserverForCall(ctx, c.stateForCall())
218-
if err != nil {
219-
// Note: any errors here are not "observed" and do not count towards the breaker's failure rate.
220-
// This includes:
221-
// - ErrCircuitOpen
222-
// - ErrConcurrencyLimit (for blocking limited circuits)
223-
// - context timeouts while blocked on concurrency limit
224-
// And any other errors that may be returned by optional breaker wrappers.
225-
return out, err
226-
}
211+
func Wrap[IN, OUT any](c *Circuit, f WrappableFunc[IN, OUT]) WrappableFunc[IN, OUT] {
212+
return func(ctx context.Context, in IN) (out OUT, err error) {
213+
obs, err := c.observerFactory.ObserverForCall(ctx, c.stateForCall())
214+
if err != nil {
215+
// Note: any errors here are not "observed" and do not count towards the breaker's failure rate.
216+
// This includes:
217+
// - ErrCircuitOpen
218+
// - ErrConcurrencyLimit (for blocking limited circuits)
219+
// - context timeouts while blocked on concurrency limit
220+
// And any other errors that may be returned by optional breaker wrappers.
221+
return out, err
222+
}
227223

228-
// ensure we dedup the final - potentially wrapped - observer.
229-
obs = dedupObservableCall(obs)
224+
// ensure we dedup the final - potentially wrapped - observer.
225+
obs = dedupObservableCall(obs)
230226

231-
obsCtx, cancel := context.WithCancelCause(ctx)
232-
defer cancel(errWrappedFunctionDone)
227+
obsCtx, cancel := context.WithCancelCause(ctx)
228+
defer cancel(errWrappedFunctionDone)
233229

234-
// TODO: we could skip this if we could ensure the original context has neither cancellation nor deadline
235-
go c.observeCtx(obs, obsCtx)
230+
// TODO: we could skip this if we could ensure the original context has neither cancellation nor deadline
231+
go c.observeCtx(obs, obsCtx)
236232

237-
defer func() {
238-
// ensure we also open the breaker on panics
239-
if err := recover(); err != nil {
240-
obs.Observe(true)
241-
panic(err) // let the caller deal with panics
242-
}
243-
obs.Observe(c.options.isFailure(err))
244-
}()
233+
defer func() {
234+
// ensure we also open the breaker on panics
235+
if err := recover(); err != nil {
236+
obs.Observe(true)
237+
panic(err) // let the caller deal with panics
238+
}
239+
obs.Observe(c.options.isFailure(err))
240+
}()
245241

246-
return c.f(ctx, in)
242+
return f(ctx, in)
243+
}
247244
}
248245

249246
// errWrappedFunctionDone is used to distinguish between internal and external (to the lib) context cancellations.
250247
var errWrappedFunctionDone = errors.New("wrapped function done")
251248

252249
// observeCtx observes the given context for cancellation and records it as a failure.
253250
// It assumes [Observer] is idempotent and deduplicates calls itself.
254-
func (c *Circuit[IN, OUT]) observeCtx(obs Observer, ctx context.Context) {
251+
func (c *Circuit) observeCtx(obs Observer, ctx context.Context) {
255252
// We want to observe a context error as soon as possible to open the breaker, but at the same time we want to
256253
// keep the call to the wrapped function synchronous to avoid all pitfalls that come with asynchronicity.
257254
<-ctx.Done()

0 commit comments

Comments
 (0)