diff --git a/LICENSE b/LICENSE index f10e67e..80b851e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 wmdillon +Copyright (c) 2026 William Dillon Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 38abc55..daab14a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ -# backoff - +# package backoff +Provides a simple Backoff interface, and currently a single Backoff type (FibonacciBackoff). This can be used to control backoff between requests. It includes a simple function for adding Jitter as well. The `cli` can be built with `go build -C cli`, and can be used for sleeping by scripts. \ No newline at end of file diff --git a/backoff.go b/backoff.go new file mode 100644 index 0000000..9778f73 --- /dev/null +++ b/backoff.go @@ -0,0 +1,12 @@ +package backoff + +import ( + "context" + "time" +) + +type Backoff interface { + Reset() + Next() time.Duration + After(context.Context) <-chan time.Time +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..aff3aaa --- /dev/null +++ b/cli/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "backoff/fibonacci" + "context" + "flag" + "fmt" + "math" + "os" + "strconv" + "time" +) + +func FormatOutput(pause time.Duration, ns bool) string { + switch ns { + case true: + return strconv.FormatInt(pause.Nanoseconds(), 10) + default: + return pause.String() + } +} + +func main() { + start := time.Now() + var ( + doPanic bool + doSleep bool + multiplier time.Duration + jitter time.Duration + iteration int64 + nanosecondsOutput bool + ) + flag.BoolVar(&doPanic, "panic", false, "panic instead of overflowing values (default returns the max value instead of overflowing).") + flag.BoolVar(&doSleep, "sleep", false, "sleep for the resulting duration after calculating it. prints the run duration of the program instead of the calculated pause duration.") + flag.DurationVar(&multiplier, "multiplier", time.Millisecond, "pause multiplier.") + flag.DurationVar(&jitter, "jitter", 0, "jitter boundary. pause duration is randomly selected in the range of resultDuration+-jitter.") + flag.Int64Var(&iteration, "iteration", 1, "iteration to use for calculating pause time.") + flag.BoolVar(&nanosecondsOutput, "ns", false, "outputs int64 nanoseconds instead of string.") + flag.Parse() + + if jitter < 0 { + jitter = time.Duration(int(math.Abs(float64(jitter.Nanoseconds())))) + } + if iteration >= int64(len(fibonacci.TheFibonacciSequence)) { + switch doPanic { + case true: + panic(fmt.Sprintf("error: input %d would overflow int64\n", iteration)) + default: + maxIteration := int64(len(fibonacci.TheFibonacciSequence) - 1) + fmt.Fprintf(os.Stderr, "iteration %d would overflow int64 - using %d\n", iteration, maxIteration) + iteration = maxIteration + } + } + + backoff := &fibonacci.FibonacciBackoff{ + Iteration: iteration, + PauseMultiplier: multiplier, + Jitter: jitter, + } + + var response string + switch doSleep { + case true: + <-backoff.After(context.Background()) + response = FormatOutput(time.Since(start), nanosecondsOutput) + default: + response = FormatOutput(backoff.Next(), nanosecondsOutput) + } + fmt.Println(response) + +} diff --git a/fibonacci/fibonacci.go b/fibonacci/fibonacci.go new file mode 100644 index 0000000..2738fe3 --- /dev/null +++ b/fibonacci/fibonacci.go @@ -0,0 +1,110 @@ +package fibonacci + +import ( + "backoff/utilities" + "context" + "fmt" + "time" +) + +var ( + DefaultMultiplier = time.Nanosecond +) + +type FibonacciBackoff struct { + Iteration int64 + MaxIteration int64 // allows a lower limit than the default len(TheFibonacciSequence) + PauseMultiplier time.Duration + Jitter time.Duration + MaxPause time.Duration + foundMax int64 // stores the last successful Iteration. factors in MaxPause and MaxIteration. +} + +// resets Iteration and foundMax - presumes users might +// also change the PauseMultiplier so we rediscover +// foundMax. +func (f *FibonacciBackoff) Reset() { + f.Iteration = 0 + f.foundMax = 0 +} + +func (f *FibonacciBackoff) Next() time.Duration { + multiplier := f.PauseMultiplier + if multiplier <= 0 { + multiplier = DefaultMultiplier + } + pause := time.Duration(Fibonacci(f.Iteration)) + if product, overflows := utilities.ProductWouldOverflowInt64(pause.Nanoseconds(), multiplier.Nanoseconds()); overflows { + // cowardly refusal to overflow - return time.Duration(math.MaxInt64) + // we can't calculate jitter, because that could overflow. + // realistically, this should never happen, we're talking about almost 300 years. + f.foundMax = f.Iteration - 1 + f.Iteration = f.foundMax + pause = time.Duration(Fibonacci(f.Iteration)) * multiplier + } else if f.MaxPause > 0 && time.Duration(product) > f.MaxPause { + f.foundMax = f.Iteration + pause = f.MaxPause + } else { + pause = time.Duration(product) + } + + // increment f.Iteration if appropriate + maxIteration := int64(len(TheFibonacciSequence) + 1) + if f.MaxIteration > 0 { + maxIteration = f.MaxIteration + } + if f.foundMax == 0 { + if f.Iteration+1 <= maxIteration { + f.Iteration++ + } else { + f.foundMax = f.Iteration + } + } else { + f.Iteration = f.foundMax + } + + // apply jitter if requested + if pause > 0 && f.Jitter > 0 { + pause = utilities.ApplyJitter(pause, f.Jitter) + } + + return pause +} + +func (f *FibonacciBackoff) After(ctx context.Context) <-chan time.Time { + pause := f.Next() + results := make(chan time.Time, 1) + go func() { + defer close(results) + select { + case t := <-time.After(pause): + results <- t + case <-ctx.Done(): + results <- time.Now() + } + }() + return results +} + +func Fibonacci(n int64) int64 { + switch { + case n >= int64(len(TheFibonacciSequence)): + panic(fmt.Sprintf("invalid input: %d overflows int64\n", + n)) + default: + return TheFibonacciSequence[n] + } +} + +var TheFibonacciSequence = [93]int64{ + 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, + 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, + 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229, + 832040, 1346269, 2178309, 3524578, 5702887, 9227465, 14930352, 24157817, 39088169, 63245986, + 102334155, 165580141, 267914296, 433494437, 701408733, 1134903170, 1836311903, 2971215073, 4807526976, 7778742049, + 12586269025, 20365011074, 32951280099, 53316291173, 86267571272, 139583862445, 225851433717, 365435296162, 591286729879, 956722026041, + 1548008755920, 2504730781961, 4052739537881, 6557470319842, 10610209857723, 17167680177565, 27777890035288, 44945570212853, 72723460248141, 117669030460994, + 190392490709135, 308061521170129, 498454011879264, 806515533049393, 1304969544928657, 2111485077978050, 3416454622906707, 5527939700884757, 8944394323791464, 14472334024676221, + 23416728348467685, 37889062373143906, 61305790721611591, 99194853094755497, 160500643816367088, 259695496911122585, 420196140727489673, 679891637638612258, 1100087778366101931, 1779979416004714189, + 2880067194370816120, 4660046610375530309, 7540113804746346429, +} diff --git a/fibonacci/fibonacci_test.go b/fibonacci/fibonacci_test.go new file mode 100644 index 0000000..2a37e86 --- /dev/null +++ b/fibonacci/fibonacci_test.go @@ -0,0 +1,135 @@ +package fibonacci + +import ( + "backoff/utilities" + "context" + "fmt" + "math/big" + "testing" + "time" +) + +func wouldOverflow(a, b int64) (product int64, overflows bool) { + results := new(big.Int).Mul(big.NewInt(a), big.NewInt(b)) + return results.Int64(), results.IsInt64() +} + +func fibonacci(n int64) int64 { + switch { + case n <= 0: + return 0 + case n == 1: + return 1 + default: + var a, b int64 = 0, 1 + for i := int64(2); i <= n; i++ { + a, b = b, a+b + } + return b + } +} + +func TestFibonacciResultsArray(t *testing.T) { + for n, got := range TheFibonacciSequence { + want := fibonacci(int64(n)) + if want != got { + t.Fatalf("error for input %d: wanted %d; got %d\n", n, want, got) + } + } +} + +func TestFibonacciFunc(t *testing.T) { + for n := range len(TheFibonacciSequence) { + want := fibonacci(int64(n)) + got := fibonacci(int64(n)) + if want != got { + t.Fatalf("error for input %d: wanted %d; got %d\n", n, want, got) + } + } +} + +func TestFibonacciBackoffNextDoesNotOverflow(t *testing.T) { + backoff := FibonacciBackoff{} + for n, want := range TheFibonacciSequence { + got := backoff.Next() + if time.Duration(want) != got { + t.Fatalf("error for %d: wanted %s; got %s\n", n, time.Duration(want), time.Duration(got)) + } + } + backoff.Reset() + if want, got := int64(0), backoff.Iteration; want != got { + t.Fatalf("error: wanted %d; got %d after Reset()\n", want, got) + } + var previous time.Duration + backoff.PauseMultiplier = time.Second + for input, output := range TheFibonacciSequence { + var want time.Duration + switch _, overflows := utilities.ProductWouldOverflowInt64(output, backoff.PauseMultiplier.Nanoseconds()); overflows { + case true: + want = previous + default: + want = time.Duration(output) * backoff.PauseMultiplier + } + got := backoff.Next() + if want != got { + t.Fatalf("error for %d (iteration=%d); wanted %s; got %s\n", input, backoff.Iteration, want, got) + } + previous = got + } +} + +func TestFibonacciBackoffIterationLimit(t *testing.T) { + backoff := FibonacciBackoff{MaxIteration: 5} + for input, output := range TheFibonacciSequence { + want := time.Duration(output) + if input >= int(backoff.MaxIteration) { + if backoff.Iteration > backoff.MaxIteration { + t.Fatalf("error: backoff.Iteration %d > max %d (%+v)\n", backoff.Iteration, backoff.MaxIteration, backoff) + } + want = time.Duration(fibonacci(5)) + } + got := backoff.Next() + if want != got { + t.Fatalf("error for %d: wanted %s; got %s\n", input, want, got) + } + + } +} + +func TestFibonacciPauseLimit(t *testing.T) { + backoff := FibonacciBackoff{MaxPause: time.Second} + for input := range TheFibonacciSequence { + if got := backoff.Next(); got > time.Second { + t.Fatalf("error for %d: expected max %s; got %s\n", input, time.Second, got) + } + } + // test with jitter + backoff.Reset() + backoff.Jitter = time.Millisecond * 100 + backoff.PauseMultiplier = time.Second + max := time.Second + backoff.Jitter + min := time.Second - backoff.Jitter + for input := range TheFibonacciSequence { + if got := backoff.Next(); input > 0 && (got > max || got < min) { + t.Fatalf("error for %d: expected value in range %s - %s; got %s\n", input, min, max, got) + } + } + fmt.Printf("finished\n") +} + +func TestFibonacciBackoffAfter(t *testing.T) { + backoff := FibonacciBackoff{Iteration: 1, PauseMultiplier: time.Millisecond} + for i := 1; i <= 5; i++ { + want := time.Duration(fibonacci(int64(i))) * time.Millisecond + min, max := want-time.Millisecond*10, want+time.Millisecond*10 + got := func() (took time.Duration) { + start := time.Now() + defer func() { took = time.Since(start) }() + <-backoff.After(context.Background()) + return + }() + if got > max || got < min { + t.Fatalf("error: wanted value in range %s-%s; got %s\n", min, max, got) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5a309f3 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module backoff + +go 1.25.5 diff --git a/utilities/jitter.go b/utilities/jitter.go new file mode 100644 index 0000000..2d0d923 --- /dev/null +++ b/utilities/jitter.go @@ -0,0 +1,24 @@ +package utilities + +import ( + "math" + "math/rand/v2" + "time" +) + +var ( + ApplyJitter = DefaultApplyJitter +) + +func DefaultApplyJitter(pauseDuration time.Duration, jitter time.Duration) time.Duration { + switch { + case jitter == 0, pauseDuration == 0: + return pauseDuration + case jitter > pauseDuration: + pauseDuration, jitter = jitter, pauseDuration + } + min := pauseDuration.Nanoseconds() - jitter.Nanoseconds() + max := pauseDuration.Nanoseconds() + jitter.Nanoseconds() + rnd := int64(math.Max(float64(rand.Int64N(max-min)+min), 0)) + return time.Duration(rnd) +} diff --git a/utilities/math.go b/utilities/math.go new file mode 100644 index 0000000..44e987d --- /dev/null +++ b/utilities/math.go @@ -0,0 +1,8 @@ +package utilities + +import "math/big" + +func ProductWouldOverflowInt64(a, b int64) (product int64, overflows bool) { + results := new(big.Int).Mul(big.NewInt(a), big.NewInt(b)) + return results.Int64(), !results.IsInt64() +}