or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

backoff-policies.mdindex.mdretry-operations.mdticker.md
tile.json

ticker.mddocs/

Ticker-Based Retries

Ticker provides a channel-based mechanism for retry operations, similar to time.Ticker. It delivers ticks at intervals determined by a backoff policy, allowing integration with Go's channel-based concurrency patterns.

Ticker Type

// Ticker holds a channel that delivers ticks of a clock at times
// reported by a BackOff
//
// Ticks will continue to arrive when the previous operation is still running,
// so operations that take a while to fail could run in quick succession
type Ticker struct {
    // C is the channel on which ticks are delivered
    C <-chan time.Time
}

Creating Tickers

NewTicker

Creates a ticker with the default timer implementation.

// NewTicker returns a new Ticker containing a channel that will send
// the time at times specified by the BackOff argument
//
// Ticker is guaranteed to tick at least once
// The channel is closed when Stop method is called or BackOff stops
//
// It is not safe to manipulate the provided backoff policy
// (notably calling NextBackOff or Reset) while the ticker is running
func NewTicker(b BackOff) *Ticker

NewTickerWithTimer

Creates a ticker with a custom timer implementation.

// NewTickerWithTimer returns a new Ticker with a custom timer
// A default timer that uses system timer is used when nil is passed
func NewTickerWithTimer(b BackOff, timer Timer) *Ticker
// Timer interface for custom timer implementations
type Timer interface {
    // Start the timer to fire after the given duration
    // Can be called multiple times to reset the timer
    Start(duration time.Duration)

    // Stop the timer and free resources
    Stop()

    // C returns the channel that receives the time when timer fires
    // Should return the same channel instance across calls
    C() <-chan time.Time
}

Ticker Methods

Stop

// Stop turns off a ticker
// After Stop, no more ticks will be sent
func (t *Ticker) Stop()

Basic Usage

Simple Retry Loop

import (
    "log"
    "github.com/cenkalti/backoff/v4"
)

operation := func() error {
    // Your operation that may fail
    return performTask()
}

ticker := backoff.NewTicker(backoff.NewExponentialBackOff())
defer ticker.Stop()

var err error
for range ticker.C {
    if err = operation(); err != nil {
        log.Printf("Operation failed: %v, retrying...", err)
        continue
    }

    // Success - stop the ticker
    ticker.Stop()
    break
}

if err != nil {
    log.Fatalf("Operation failed after retries: %v", err)
}

With Context

import (
    "context"
    "time"
    "github.com/cenkalti/backoff/v4"
)

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

b := backoff.WithContext(backoff.NewExponentialBackOff(), ctx)
ticker := backoff.NewTicker(b)
defer ticker.Stop()

var err error
for {
    select {
    case <-ticker.C:
        if err = operation(); err != nil {
            log.Printf("Retry attempt failed: %v", err)
            continue
        }
        ticker.Stop()
        return nil

    case <-ctx.Done():
        return ctx.Err()
    }
}

Advanced Usage Patterns

Multiple Channel Operations

Integrate ticker with other channel operations:

import (
    "context"
    "github.com/cenkalti/backoff/v4"
)

func RetryWithUpdates(ctx context.Context) error {
    ticker := backoff.NewTicker(
        backoff.WithContext(backoff.NewExponentialBackOff(), ctx),
    )
    defer ticker.Stop()

    updateChan := make(chan Update)

    for {
        select {
        case <-ticker.C:
            if err := operation(); err != nil {
                log.Printf("Retry: %v", err)
                continue
            }
            ticker.Stop()
            return nil

        case update := <-updateChan:
            // Handle updates while retrying
            processUpdate(update)

        case <-ctx.Done():
            return ctx.Err()
        }
    }
}

Ticker with Notification

Track retry attempts with ticker:

import (
    "log"
    "github.com/cenkalti/backoff/v4"
)

func RetryWithMetrics() error {
    ticker := backoff.NewTicker(backoff.NewExponentialBackOff())
    defer ticker.Stop()

    attemptNum := 0
    var err error

    for t := range ticker.C {
        attemptNum++
        log.Printf("Attempt %d at %v", attemptNum, t)

        if err = operation(); err != nil {
            metrics.RecordRetry(attemptNum, err)
            continue
        }

        ticker.Stop()
        metrics.RecordSuccess(attemptNum)
        break
    }

    return err
}

Custom Timer Implementation

Use custom timer for testing or special timing requirements:

import (
    "time"
    "github.com/cenkalti/backoff/v4"
)

// Mock timer for testing
type MockTimer struct {
    ch chan time.Time
}

func (t *MockTimer) Start(duration time.Duration) {
    // Custom timing logic
    go func() {
        time.Sleep(duration)
        t.ch <- time.Now()
    }()
}

func (t *MockTimer) Stop() {
    close(t.ch)
}

func (t *MockTimer) C() <-chan time.Time {
    return t.ch
}

// Usage
mockTimer := &MockTimer{ch: make(chan time.Time, 1)}
ticker := backoff.NewTickerWithTimer(
    backoff.NewExponentialBackOff(),
    mockTimer,
)

Comparison: Ticker vs Retry Functions

Use Ticker When:

  1. Channel-based workflows: Integrating with existing channel-based code
  2. Multiple select cases: Need to handle multiple channel operations simultaneously
  3. Fine-grained control: Want explicit control over retry timing and flow
  4. Concurrent operations: Coordinating retry with other goroutines
// Example: Coordinating with multiple channels
select {
case <-ticker.C:
    // Retry operation
case msg := <-messageChan:
    // Handle message
case <-ctx.Done():
    // Handle cancellation
}

Use Retry Functions When:

  1. Simple retry logic: Basic retry with backoff
  2. Less boilerplate: Simpler code for common cases
  3. Return values: Need to return data from operation
  4. Notifications: Want callbacks on each retry attempt
// Example: Simple retry
err := backoff.Retry(operation, backoff.NewExponentialBackOff())

Complete Example: HTTP Client

import (
    "context"
    "fmt"
    "net/http"
    "time"
    "github.com/cenkalti/backoff/v4"
)

func FetchWithRetry(ctx context.Context, url string) ([]byte, error) {
    b := backoff.WithContext(
        backoff.NewExponentialBackOff(
            backoff.WithMaxElapsedTime(2*time.Minute),
        ),
        ctx,
    )

    ticker := backoff.NewTicker(b)
    defer ticker.Stop()

    var (
        resp *http.Response
        err  error
    )

    for {
        select {
        case t := <-ticker.C:
            log.Printf("Attempt at %v", t)

            req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
            if err != nil {
                return nil, err
            }

            resp, err = http.DefaultClient.Do(req)
            if err != nil {
                log.Printf("Request failed: %v", err)
                continue
            }

            if resp.StatusCode == 200 {
                defer resp.Body.Close()
                ticker.Stop()
                return io.ReadAll(resp.Body)
            }

            resp.Body.Close()

            if resp.StatusCode >= 400 && resp.StatusCode < 500 {
                // Client error - don't retry
                return nil, fmt.Errorf("client error: %d", resp.StatusCode)
            }

            log.Printf("Server error %d, retrying...", resp.StatusCode)

        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
}

Important Behaviors

Guaranteed First Tick

Ticker is guaranteed to tick at least once immediately after creation:

ticker := backoff.NewTicker(b)
// First tick arrives immediately
<-ticker.C

Channel Closure

The ticker channel is closed when:

  • Stop() is called
  • The backoff policy returns Stop
  • Context is canceled (when using WithContext)
ticker := backoff.NewTicker(b)

// Loop automatically exits when channel closes
for range ticker.C {
    // Handle ticks
}

// Channel is closed, loop exits

Tick Timing

Ticks continue to arrive even if the previous operation is still running:

ticker := backoff.NewTicker(backoff.NewConstantBackOff(1*time.Second))

for range ticker.C {
    // This takes 5 seconds
    time.Sleep(5 * time.Second)

    // Next tick may arrive immediately if it was waiting
}

This means operations that take longer than the backoff interval could run in quick succession.

Backoff Policy Safety

Do not modify the backoff policy while the ticker is running:

b := backoff.NewExponentialBackOff()
ticker := backoff.NewTicker(b)

// DON'T DO THIS - not safe
// b.Reset()
// b.NextBackOff()

ticker.Stop()

// Safe to modify after stopping
b.Reset()

Performance Considerations

Goroutine Usage

Each ticker spawns a goroutine. Always call Stop() to clean up:

ticker := backoff.NewTicker(b)
defer ticker.Stop() // Ensures cleanup

Memory Usage

The ticker channel is buffered with size 1. Slow consumers won't block the ticker, but may miss ticks if they don't read fast enough:

ticker := backoff.NewTicker(b)

// Slow consumer
for t := range ticker.C {
    time.Sleep(10 * time.Second) // Very slow
    // May miss intermediate ticks
}

Context Integration

When using WithContext, the ticker automatically stops on context cancellation:

ctx, cancel := context.WithCancel(context.Background())
ticker := backoff.NewTicker(backoff.WithContext(b, ctx))

// Ticker stops automatically when context is canceled
cancel()

// Channel will be closed

Testing with Ticker

Use custom timer for deterministic tests:

import (
    "testing"
    "time"
    "github.com/cenkalti/backoff/v4"
)

func TestRetryOperation(t *testing.T) {
    // Create controlled timer
    timer := &TestTimer{
        ch: make(chan time.Time, 10),
    }

    // Manually control tick timing
    timer.ch <- time.Now()

    ticker := backoff.NewTickerWithTimer(
        backoff.NewConstantBackOff(time.Second),
        timer,
    )
    defer ticker.Stop()

    // Test with controlled timing
    select {
    case <-ticker.C:
        // Expected tick
    case <-time.After(100 * time.Millisecond):
        t.Fatal("Expected tick")
    }
}

Implementation Notes

  • Thread Safety: Ticker is safe for concurrent use from multiple goroutines
  • Channel Direction: C field is receive-only to prevent external sends
  • Stop Idempotency: Calling Stop() multiple times is safe (uses sync.Once)
  • Context Awareness: When using BackOffContext, ticker respects context cancellation
  • Immediate First Tick: First tick is sent immediately before checking backoff intervals