DEV Community

Cover image for Retries and Backoff in Go: Where They Belong in a Hexagonal Service
Gabriel Anhaia
Gabriel Anhaia

Posted on

Retries and Backoff in Go: Where They Belong in a Hexagonal Service


A downstream service starts returning 503 for ten seconds. Your
Go service has a retry: three attempts, 100ms apart. Every
request that lands during those ten seconds fires three times.
Your traffic to the struggling service triples at the exact
moment it can least afford it. The downstream stays down longer
because you and every other caller are hammering it in lockstep,
all retrying at the same 100ms tick.

That is a retry storm, and the fix has been known for a decade.
The AWS Architecture Blog post Exponential Backoff And
Jitter

laid out the math: spread retries over a growing, randomized
window so callers stop synchronizing. The interesting question
for a Go service is not the formula. It is where the retry
lives
. Get the placement wrong and no amount of jitter saves
you.

The domain never retries

Start with the rule, because it decides everything else.

Your domain layer holds business logic. "An order over $500
needs manager approval." "A refund cannot exceed the original
charge." None of that knows the word retry. A retry is a fact
about an unreliable network, a rate-limited API, a database
that briefly refused a connection. Those are transport
concerns. They belong to the code that talks to the transport:
the adapter.

Here is the port the domain sees. It is an interface, and it
says nothing about attempts or delays.

// port/payment.go
package port

import "context"

type PaymentGateway interface {
    Charge(
        ctx context.Context,
        orderID string,
        cents int64,
    ) (string, error)
}
Enter fullscreen mode Exit fullscreen mode

The application service calls Charge once. It does not loop.
If it fails, it fails, and the domain decides what a failed
charge means for the order.

// app/checkout.go
func (s *Checkout) Confirm(
    ctx context.Context, orderID string,
) error {
    order, err := s.orders.Get(ctx, orderID)
    if err != nil {
        return err
    }
    txID, err := s.gateway.Charge(
        ctx, order.ID, order.TotalCents,
    )
    if err != nil {
        return fmt.Errorf("charge: %w", err)
    }
    order.MarkPaid(txID)
    return s.orders.Save(ctx, order)
}
Enter fullscreen mode Exit fullscreen mode

If you put the retry loop in Confirm, every business method
that touches the network grows the same loop, the domain starts
importing time, and your unit tests wait on real backoff
delays. The retry has leaked out of transport and into logic.

Backoff lives in the adapter

The adapter implements PaymentGateway. That is the one place
that knows there is an HTTP call underneath, so it is the one
place that gets to retry.

The full-jitter formula from the AWS post: pick a random delay
between zero and an exponentially growing cap. Random spread is
what breaks the lockstep.

// adapter/backoff.go
package adapter

import (
    "math/rand/v2"
    "time"
)

func fullJitter(
    attempt int, base, max time.Duration,
) time.Duration {
    // cap = base * 2^attempt, clamped to max
    cap := base << attempt
    if cap > max || cap <= 0 {
        cap = max
    }
    return time.Duration(rand.Int64N(int64(cap)))
}
Enter fullscreen mode Exit fullscreen mode

Go 1.22 promoted math/rand/v2 to the standard library, and
rand.Int64N is safe for concurrent use with no seeding
ceremony. The attempt shift gives you 0, base, 2×base, 4×base
as the ceiling, and each real delay is a uniform draw under
that ceiling. Two callers that fail at the same millisecond now
wake up at different times.

Respect the context deadline

This is the part most retry loops get wrong. A retry that
ignores the caller's deadline is worse than no retry. If the
HTTP handler upstream has a 2-second budget and your adapter
sleeps for 4 seconds across attempts, you have burned the
budget on waiting and returned nothing.

The loop has to watch two clocks: the backoff timer and
ctx.Done(). Whichever fires first wins.

// adapter/retry.go
package adapter

import (
    "context"
    "time"
)

func retry(
    ctx context.Context,
    max int,
    op func(context.Context) error,
) error {
    const base = 50 * time.Millisecond
    const cap = 2 * time.Second

    var err error
    for attempt := 0; attempt < max; attempt++ {
        err = op(ctx)
        if err == nil {
            return nil
        }
        if !retryable(err) {
            return err
        }
        delay := fullJitter(attempt, base, cap)

        t := time.NewTimer(delay)
        select {
        case <-ctx.Done():
            t.Stop()
            return ctx.Err()
        case <-t.C:
        }
    }
    return err
}
Enter fullscreen mode Exit fullscreen mode

The select is the whole point. If the context is cancelled or
its deadline passes while you are waiting out the backoff, you
stop immediately and return ctx.Err(). You never sleep past
the budget the caller handed you. time.NewTimer plus
t.Stop() avoids leaking a timer when the context wins the
race.

Retry the right errors, not all of them

A retry only helps for failures that might succeed next time: a
dropped connection, a 503, a 429, a timeout on the downstream.
Retrying a 400 or a "card declined" is pointless and, for a
non-idempotent charge, dangerous. You want a predicate that
decides.

// adapter/retryable.go
package adapter

import (
    "context"
    "errors"
    "net"
)

func retryable(err error) bool {
    // caller's deadline or cancel: never retry
    if errors.Is(err, context.DeadlineExceeded) ||
        errors.Is(err, context.Canceled) {
        return false
    }
    var netErr net.Error
    if errors.As(err, &netErr) && netErr.Timeout() {
        return true
    }
    var status *StatusError
    if errors.As(err, &status) {
        return status.Code == 429 ||
            status.Code >= 500
    }
    return false
}

type StatusError struct{ Code int }

func (e *StatusError) Error() string {
    return "unexpected status"
}
Enter fullscreen mode Exit fullscreen mode

errors.As walks the wrapped chain, so a StatusError buried
under three fmt.Errorf("...: %w", err) calls still gets
matched. The default is false: an error you do not recognize
is not retried. Unknown failures should surface, not loop.

Wiring it into the adapter

The gateway adapter holds the HTTP client and runs its calls
through retry. The domain and the application service never
see any of it.

// adapter/payment_http.go
package adapter

import "context"

type HTTPGateway struct {
    client *Client
    max    int
}

func (g *HTTPGateway) Charge(
    ctx context.Context,
    orderID string,
    cents int64,
) (string, error) {
    var txID string
    err := retry(ctx, g.max,
        func(ctx context.Context) error {
            id, err := g.client.postCharge(
                ctx, orderID, cents,
            )
            if err != nil {
                return err
            }
            txID = id
            return nil
        },
    )
    return txID, err
}
Enter fullscreen mode Exit fullscreen mode

One caveat worth stating out loud: retrying a charge is only
safe if the downstream treats it as idempotent. Send an
idempotency key with the request (most payment APIs support
one) so a retried postCharge cannot double-charge. The retry
lives in the adapter, and so does the responsibility for making
it safe. That is the right home for both.

What the placement bought you

The domain stayed pure. Confirm calls Charge once and reads
like the business rule it encodes. The retry policy is one file
you can tune, test with a fake clock, and reason about on its
own. Swap the HTTP gateway for a gRPC one and the backoff moves
with it, because it was never anywhere else. The context
deadline is honored end to end, so an upstream timeout cancels
the whole chain instead of leaving a goroutine sleeping on a
timer nobody is waiting for.

Retries are a transport detail. Keep them at the transport
boundary, and the rest of the service does not have to know
that the network is unreliable.


If this was useful

The mechanics here — math/rand/v2, context deadlines,
errors.As over wrapped chains, time.Timer races in a
select — are the language and runtime details The Complete
Guide to Go Programming
covers in depth. Keeping the retry at
the adapter and out of the domain is the boundary discipline
Hexagonal Architecture in Go is built around: ports that stay
ignorant of transport, adapters that own the messy parts.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)