Typed results
An AsyncFunc[T] returns a T, so Race and Retry return one and All returns a []T. No
interface{}, no type assertions.
Run Go functions at once and get what they return, typed. The first to succeed wins a Race and cancels the rest, All waits for each one, and Retry calls one until it succeeds.
go get github.com/vardius/gollback/v2 // The first replica to answer wins,// and the other request is cancelled.user, err := gollback.Race(ctx, func(ctx context.Context) (User, error) { return primary.User(ctx, id) }, func(ctx context.Context) (User, error) { return replica.User(ctx, id) },)// Every shop at once: a price or an error// for each, in the order of the shops.prices, errs := gollback.All(ctx, func(ctx context.Context) (Price, error) { return shopA.Price(ctx, sku) }, func(ctx context.Context) (Price, error) { return shopB.Price(ctx, sku) },)// Up to five calls, until one succeeds// or ctx is done.cfg, err := gollback.Retry(ctx, 5, func(ctx context.Context) (Config, error) { return store.Load(ctx, "app") },)Features
Typed results
An AsyncFunc[T] returns a T, so Race and Retry return one and All returns a []T. No
interface{}, no type assertions.
Cancellation built in
Every function gets a context. The moment one wins a race, the others’ context is cancelled, so their requests stop.
Every error kept
When every racer fails, you get all their errors joined. All returns each function’s error in its
place.
Lean
Retry allocates nothing, All runs the first function on your goroutine, and tests guard the
allocation counts.
Deterministic tests
Tested on a fake clock with testing/synctest: timings are exact and nothing waits for real. 100% coverage.
Small API
Three functions and one type. Nothing to configure and no dependencies beyond the standard library.
Performance
v2 starts one goroutine per function and nothing else, keeps results in one place instead of passing them through channels, and never allocates to retry. Racing one function went from 762 ns to 348 ns. With 16 callers at once, the time is wall time per call across all of them, so it measures throughput.
| Scenario | v1 | v2 |
|---|---|---|
| 1 function | 762.3 ns13 allocs | 347.5 ns5 allocs |
| 4 functions | 1.95 µs22 allocs | 1.35 µs8 allocs |
| 16 functions | 5.63 µs58 allocs | 3.94 µs20 allocs |
| 4 functions, all fail | 2.19 µs22 allocs | 1.08 µs11 allocs |
| 4 functions, 16 callers at once | 445.6 ns22 allocs | 284.4 ns8 allocs |
Apple M4 Max, Go 1.27.0, median of 5 runs.
| Scenario | v1 | v2 |
|---|---|---|
| 1 function | 284.5 ns5 allocs | 15.2 ns2 allocs |
| 4 functions | 950.1 ns11 allocs | 718.9 ns6 allocs |
| 16 functions | 3.08 µs35 allocs | 2.66 µs18 allocs |
| 4 functions, 16 callers at once | 313.2 ns11 allocs | 191.4 ns6 allocs |
Apple M4 Max, Go 1.27.0, median of 5 runs.
| Scenario | v1 | v2 |
|---|---|---|
| 5 failed attempts | 24.6 ns0 allocs | 13.4 ns0 allocs |
Apple M4 Max, Go 1.27.0, median of 5 runs.
Upgrading
The functions are the same. They are generic now, so the type assertions go, and a few edge cases changed: all errors when every racer fails, and no results beside errors.
res, err := gollback.Race(ctx, func(ctx context.Context) (interface{}, error) { return primary.User(ctx, id) }, func(ctx context.Context) (interface{}, error) { return replica.User(ctx, id) },)user := res.(User)user, err := gollback.Race(ctx, func(ctx context.Context) (User, error) { return primary.User(ctx, id) }, func(ctx context.Context) (User, error) { return replica.User(ctx, id) },)Install the module, hand Race two functions, and get the faster answer. The guide walks through All and Retry too.