Hooks¶
Hooks are callbacks registered on a relay Client that fire at well-defined points in the request lifecycle. They are the primary extension point for cross-cutting concerns such as logging, metrics, request mutation, and error enrichment - all without touching the core request/response logic.
relay provides three hook types:
| Hook | When it fires | Return value |
|---|---|---|
BeforeRetryHook | Before each retry sleep | none |
BeforeRedirectHook | Before each redirect is followed | error (stops redirect chain) |
OnErrorHook | After Execute returns a non-nil error | none |
Hook function signatures¶
// BeforeRetryHookFunc is called before each retry sleep.
// attempt is 1-based (first retry = 1).
// httpResp and err reflect the result that triggered the retry; either may be nil.
type BeforeRetryHookFunc func(
ctx context.Context,
attempt int,
req *relay.Request,
httpResp *http.Response,
err error,
)
// BeforeRedirectHookFunc is called before each redirect is followed.
// Returning a non-nil error stops the redirect chain; the error propagates
// as the Execute return value.
type BeforeRedirectHookFunc func(req *http.Request, via []*http.Request) error
// OnErrorHookFunc is called when Execute returns a non-nil error.
// It is intended for logging and metrics; its return value is discarded.
type OnErrorHookFunc func(ctx context.Context, req *relay.Request, err error)
WithBeforeRetryHook¶
WithBeforeRetryHook registers a function that relay calls just before sleeping between retry attempts. It is useful for structured logging, emitting retry metrics, and mutating the request (for example, refreshing a short-lived token before the next attempt).
Basic retry logger¶
package main
import (
"context"
"log/slog"
"net/http"
"os"
"time"
relay "github.com/jhonsferg/relay"
)
func main() {
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithBeforeRetryHook(func(
ctx context.Context,
attempt int,
req *relay.Request,
resp *http.Response,
err error,
) {
statusCode := 0
if resp != nil {
statusCode = resp.StatusCode
}
slog.InfoContext(ctx, "retrying request",
"attempt", attempt,
"url", req.URL(),
"status_code", statusCode,
"error", err,
)
}),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := client.Execute(client.Get("/users"))
if err != nil {
slog.Error("request failed", "err", err)
os.Exit(1)
}
slog.Info("ok", "status", resp.StatusCode)
}
Refreshing a token before retry¶
Some short-lived tokens (JWT, OAuth access token) expire between attempts. Mutate the request inside a BeforeRetryHook to attach a fresh token:
package main
import (
"context"
"net/http"
"sync"
relay "github.com/jhonsferg/relay"
)
type tokenStore struct {
mu sync.Mutex
token string
}
func (ts *tokenStore) Refresh() string {
ts.mu.Lock()
defer ts.mu.Unlock()
// In production this would call the token endpoint.
ts.token = "new-access-token-" + "refreshed"
return ts.token
}
func main() {
store := &tokenStore{token: "initial-token"}
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithBeforeRetryHook(func(
ctx context.Context,
attempt int,
req *relay.Request,
resp *http.Response,
err error,
) {
// Only refresh when the previous response was a 401.
if resp != nil && resp.StatusCode == http.StatusUnauthorized {
fresh := store.Refresh()
req.Header("Authorization", "Bearer "+fresh)
}
}),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
_, _ = client.Execute(
client.Get("/protected").Header("Authorization", "Bearer "+store.token),
)
}
Emitting retry metrics¶
package main
import (
"context"
"net/http"
"sync/atomic"
relay "github.com/jhonsferg/relay"
)
var retryCounter int64
func main() {
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithBeforeRetryHook(func(
_ context.Context,
_ int,
_ *relay.Request,
_ *http.Response,
_ error,
) {
atomic.AddInt64(&retryCounter, 1)
// In production: metrics.Inc("http_client_retries_total")
}),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
_, _ = client.Execute(client.Get("/health"))
}
WithBeforeRedirectHook¶
WithBeforeRedirectHook registers a function that fires before relay follows each redirect. Returning a non-nil error stops the redirect chain immediately and propagates the error as the Execute return value.
Logging the redirect chain¶
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
relay "github.com/jhonsferg/relay"
)
func main() {
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithBeforeRedirectHook(func(req *http.Request, via []*http.Request) error {
slog.Info("following redirect",
"from", via[len(via)-1].URL.String(),
"to", req.URL.String(),
"hops", len(via),
)
return nil
}),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
_, _ = client.Execute(client.Get("/old-path"))
}
Blocking cross-origin redirects¶
Some security policies prohibit following redirects to a different host. Use a BeforeRedirectHook to enforce this at the client level:
package main
import (
"context"
"errors"
"net/http"
relay "github.com/jhonsferg/relay"
)
var errCrossOriginRedirect = errors.New("cross-origin redirect blocked")
func main() {
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithBeforeRedirectHook(func(req *http.Request, via []*http.Request) error {
origin := via[0].URL.Host
target := req.URL.Host
if origin != target {
return fmt.Errorf("%w: %s -> %s", errCrossOriginRedirect, origin, target)
}
return nil
}),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
resp, err := client.Execute(client.Get("/maybe-redirects"))
if errors.Is(err, errCrossOriginRedirect) {
// Handle policy violation.
_ = resp
}
}
Note The
viaslice holds every request that was already followed, starting with the original.via[0]is always the initial request;via[len(via)-1]is the most recent one.
Forwarding auth headers across redirects¶
By default the Go HTTP client strips the Authorization header when following a redirect to a different host. Explicitly re-attach it when you trust the destination:
package main
import (
"context"
"net/http"
relay "github.com/jhonsferg/relay"
)
func main() {
const token = "Bearer super-secret"
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithBeforeRedirectHook(func(req *http.Request, via []*http.Request) error {
// Re-attach the Authorization header that Go stripped.
if via[0].Header.Get("Authorization") != "" {
req.Header.Set("Authorization", via[0].Header.Get("Authorization"))
}
return nil
}),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
_, _ = client.Execute(
client.Get("/redirect-me").Header("Authorization", token),
)
}
WithOnErrorHook¶
WithOnErrorHook registers a function that relay calls after Execute returns a non-nil error. The hook's return value is discarded; it is purely for side-effects such as logging, alerting, or incrementing error counters.
Structured error logging¶
package main
import (
"context"
"log/slog"
"os"
relay "github.com/jhonsferg/relay"
)
func main() {
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithOnErrorHook(func(ctx context.Context, req *relay.Request, err error) {
slog.ErrorContext(ctx, "http request failed",
"url", req.URL(),
"method", req.Method(),
"error", err,
)
}),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
if _, err := client.Execute(client.Get("/users")); err != nil {
os.Exit(1)
}
}
Sending errors to an alerting system¶
package main
import (
"context"
"fmt"
relay "github.com/jhonsferg/relay"
)
// alerting is a stand-in for any error-tracking client (Sentry, Datadog, etc.)
func alerting(msg string) { fmt.Println("ALERT:", msg) }
func main() {
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithOnErrorHook(func(_ context.Context, req *relay.Request, err error) {
if relay.IsCircuitOpen(err) {
alerting(fmt.Sprintf("circuit open for %s", req.URL()))
}
}),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
_, _ = client.Execute(client.Get("/payments"))
}
Adding multiple hooks¶
You can call WithBeforeRetryHook, WithBeforeRedirectHook, and WithOnErrorHook multiple times. Each call appends to the existing slice; hooks are never replaced.
package main
import (
"context"
"fmt"
"net/http"
relay "github.com/jhonsferg/relay"
)
func main() {
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
// First retry hook - logging.
relay.WithBeforeRetryHook(func(_ context.Context, attempt int, req *relay.Request, _ *http.Response, _ error) {
fmt.Printf("[hook-1] retry attempt %d for %s\n", attempt, req.URL())
}),
// Second retry hook - metrics.
relay.WithBeforeRetryHook(func(_ context.Context, _ int, _ *relay.Request, _ *http.Response, _ error) {
fmt.Println("[hook-2] incrementing retry counter")
}),
// First error hook - log.
relay.WithOnErrorHook(func(_ context.Context, req *relay.Request, err error) {
fmt.Printf("[on-error-1] %s failed: %v\n", req.URL(), err)
}),
// Second error hook - alert.
relay.WithOnErrorHook(func(_ context.Context, req *relay.Request, err error) {
fmt.Printf("[on-error-2] sending alert for %s\n", req.URL())
}),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
_, _ = client.Execute(client.Get("/multi-hook-demo"))
}
Hook ordering (FIFO)¶
relay executes all hooks of the same type in first-in, first-out order - the order they were passed to relay.New. This is intentional and deterministic:
WithBeforeRetryHook(A) --> registered first --> called first
WithBeforeRetryHook(B) --> registered second --> called second
WithBeforeRetryHook(C) --> registered third --> called third
The same FIFO rule applies to WithOnErrorHook and WithBeforeRedirectHook.
Tip Put logging hooks before alerting hooks so your logs always appear before any alert fires. This makes debugging easier.
package main
import (
"context"
"fmt"
"net/http"
relay "github.com/jhonsferg/relay"
)
func main() {
order := []string{}
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithBeforeRetryHook(func(_ context.Context, _ int, _ *relay.Request, _ *http.Response, _ error) {
order = append(order, "A")
}),
relay.WithBeforeRetryHook(func(_ context.Context, _ int, _ *relay.Request, _ *http.Response, _ error) {
order = append(order, "B")
}),
relay.WithBeforeRetryHook(func(_ context.Context, _ int, _ *relay.Request, _ *http.Response, _ error) {
order = append(order, "C")
}),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
_, _ = client.Execute(client.Get("/order-demo"))
fmt.Println(order) // [A B C] on every retry
}
Complete example: logging + metrics + error enrichment¶
The example below wires all three hook types together to build a production-ready observability layer entirely outside of the request/response path:
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"sync/atomic"
"time"
relay "github.com/jhonsferg/relay"
)
// --- minimal in-process metrics -----------------------------------------
var (
totalRetries int64
totalErrors int64
totalRedirects int64
)
func incRetries() { atomic.AddInt64(&totalRetries, 1) }
func incErrors() { atomic.AddInt64(&totalErrors, 1) }
func incRedirects() { atomic.AddInt64(&totalRedirects, 1) }
// --- main ---------------------------------------------------------------
func main() {
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithTimeout(5*time.Second),
// Retry hook: log + metric.
relay.WithBeforeRetryHook(func(
ctx context.Context,
attempt int,
req *relay.Request,
resp *http.Response,
err error,
) {
incRetries()
code := 0
if resp != nil {
code = resp.StatusCode
}
slog.WarnContext(ctx, "retrying",
"attempt", attempt,
"url", req.URL(),
"status", code,
"err", err,
)
}),
// Redirect hook: metric + cross-origin guard.
relay.WithBeforeRedirectHook(func(req *http.Request, via []*http.Request) error {
incRedirects()
slog.Info("redirect",
"from", via[len(via)-1].URL.String(),
"to", req.URL.String(),
)
return nil
}),
// Error hook: metric + structured log.
relay.WithOnErrorHook(func(ctx context.Context, req *relay.Request, err error) {
incErrors()
attrs := []any{
"url", req.URL(),
"error", err,
}
switch {
case relay.IsCircuitOpen(err):
attrs = append(attrs, "class", "circuit_open")
case relay.IsTimeout(err):
attrs = append(attrs, "class", "timeout")
case errors.Is(err, relay.ErrMaxRetriesReached):
attrs = append(attrs, "class", "max_retries")
default:
attrs = append(attrs, "class", "unknown")
}
slog.ErrorContext(ctx, "request failed", attrs...)
}),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := client.Execute(client.Get("/users").WithContext(ctx))
if err != nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
fmt.Printf("retries=%d redirects=%d errors=%d\n",
atomic.LoadInt64(&totalRetries),
atomic.LoadInt64(&totalRedirects),
atomic.LoadInt64(&totalErrors),
)
}
Warning Hook functions must be goroutine-safe if the client is used from multiple goroutines concurrently. Use
sync/atomicor a mutex to guard any shared mutable state inside a hook.