Error Handling¶
relay uses Go's standard error interface throughout. Errors fall into three broad categories:
- Sentinel errors - compare with
errors.Is(e.g.relay.ErrBulkheadFull). - Typed errors - use
errors.Asor the helperrelay.IsHTTPErrorto extract structured data (e.g.*relay.HTTPError). - Classification helpers -
relay.ClassifyError,relay.IsRetryableError,relay.IsTimeout,relay.IsCircuitOpenlet you categorise any error without inspecting its type directly.
Sentinel errors¶
| Sentinel | Meaning |
|---|---|
relay.ErrCircuitOpen | Circuit breaker is open; request was rejected without being sent |
relay.ErrMaxRetriesReached | All retry attempts exhausted |
relay.ErrRateLimitExceeded | Rate limiter could not grant a token before context expired |
relay.ErrNilRequest | A nil *relay.Request was passed to Execute |
relay.ErrTimeout | Per-request timeout fired |
relay.ErrBodyTruncated | Response body exceeded the configured size limit |
relay.ErrClientClosed | Execute called after Shutdown |
relay.ErrBulkheadFull | Bulkhead slot limit reached and context cancelled |
Always use errors.Is rather than == for sentinel comparisons, because relay may wrap these errors with additional context:
package main
import (
"context"
"errors"
"fmt"
relay "github.com/jhonsferg/relay"
)
func main() {
client := relay.New(relay.WithBaseURL("https://api.example.com"))
defer client.Shutdown(context.Background()) //nolint:errcheck
_, err := client.Execute(client.Get("/data"))
if err == nil {
return
}
switch {
case errors.Is(err, relay.ErrCircuitOpen):
fmt.Println("circuit breaker is open, back off and retry later")
case errors.Is(err, relay.ErrMaxRetriesReached):
fmt.Println("all retries exhausted")
case errors.Is(err, relay.ErrBulkheadFull):
fmt.Println("too many concurrent requests, shed load")
case errors.Is(err, relay.ErrTimeout):
fmt.Println("request timed out")
case errors.Is(err, relay.ErrRateLimitExceeded):
fmt.Println("local rate limit exceeded")
default:
fmt.Printf("unexpected error: %v\n", err)
}
}
ErrBulkheadFull¶
ErrBulkheadFull is returned when the bulkhead (concurrency limiter) has no available slots and the context is cancelled or times out before one becomes free. It signals that the application is under too much load to accept more work right now.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"time"
relay "github.com/jhonsferg/relay"
)
func callWithBulkhead(client *relay.Client, path string) error {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
_, err := client.Execute(client.Get(path).WithContext(ctx))
if errors.Is(err, relay.ErrBulkheadFull) {
// Return a 503 to the caller or enqueue for later.
return fmt.Errorf("service unavailable: %w", err)
}
return err
}
func main() {
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithBulkhead(5), // max 5 concurrent in-flight requests
)
defer client.Shutdown(context.Background()) //nolint:errcheck
if err := callWithBulkhead(client, "/orders"); err != nil {
fmt.Println(err)
}
_ = http.StatusServiceUnavailable // referenced above conceptually
}
Note
ErrBulkheadFullis distinct fromErrRateLimitExceeded. The rate limiter throttles the rate of requests (requests per second), while the bulkhead limits concurrency (requests in-flight simultaneously).
Classification helpers¶
IsRetryableError¶
IsRetryableError(err, resp) returns true when the error or response indicates that retrying is worthwhile - that is, the error class is ErrorClassTransient or ErrorClassRateLimited.
package main
import (
"context"
"fmt"
"time"
relay "github.com/jhonsferg/relay"
)
func fetchWithBackoff(client *relay.Client, path string) error {
var (
resp *relay.Response
err error
)
for attempt := 1; attempt <= 3; attempt++ {
resp, err = client.Execute(client.Get(path))
if !relay.IsRetryableError(err, resp) {
break
}
fmt.Printf("attempt %d failed (retryable): %v\n", attempt, err)
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
}
return err
}
func main() {
client := relay.New(relay.WithBaseURL("https://api.example.com"))
defer client.Shutdown(context.Background()) //nolint:errcheck
if err := fetchWithBackoff(client, "/unstable"); err != nil {
fmt.Println("final error:", err)
}
}
IsTimeout¶
IsTimeout(err) returns true for both relay.ErrTimeout (per-request timeout) and context.DeadlineExceeded (caller-supplied deadline):
package main
import (
"context"
"errors"
"fmt"
"time"
relay "github.com/jhonsferg/relay"
)
func main() {
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithTimeout(2*time.Second),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
_, err := client.Execute(client.Get("/slow-endpoint"))
if relay.IsTimeout(err) {
fmt.Println("timed out - check server performance")
return
}
if err != nil {
fmt.Println("other error:", err)
}
}
Tip
IsTimeoutmatches both the relay-level timeout (WithTimeout) and any deadline inherited from a parentcontext.Context. You do not need to checkerrors.Is(err, context.DeadlineExceeded)separately.
IsCircuitOpen¶
IsCircuitOpen(err) returns true when errors.Is(err, relay.ErrCircuitOpen):
package main
import (
"context"
"fmt"
"time"
relay "github.com/jhonsferg/relay"
)
func main() {
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithCircuitBreaker(relay.CircuitBreakerConfig{
Threshold: 5,
OpenTimeout: 10 * time.Second,
}),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
_, err := client.Execute(client.Get("/payments"))
if relay.IsCircuitOpen(err) {
fmt.Println("circuit is open - returning cached response or failing fast")
}
}
Context cancellation¶
relay propagates context errors faithfully. Use errors.Is to distinguish between a caller-initiated cancellation and a deadline expiry:
package main
import (
"context"
"errors"
"fmt"
"time"
relay "github.com/jhonsferg/relay"
)
func main() {
client := relay.New(relay.WithBaseURL("https://api.example.com"))
defer client.Shutdown(context.Background()) //nolint:errcheck
// Simulate a context cancelled by the caller (e.g. HTTP handler returning early).
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
_, err := client.Execute(client.Get("/slow").WithContext(ctx))
switch {
case err == nil:
fmt.Println("success")
case errors.Is(err, context.Canceled):
fmt.Println("caller cancelled the request")
case errors.Is(err, context.DeadlineExceeded):
fmt.Println("deadline exceeded")
case relay.IsTimeout(err):
// Relay-level per-request timeout (WithTimeout).
fmt.Println("relay timeout")
default:
fmt.Println("other error:", err)
}
}
HTTP error status codes vs Go errors¶
relay does not automatically convert non-2xx responses into Go errors. A *relay.Response with StatusCode == 503 is returned alongside a nil error. Inspect the response yourself, or call resp.AsHTTPError() to get a typed error:
package main
import (
"context"
"errors"
"fmt"
relay "github.com/jhonsferg/relay"
)
func main() {
client := relay.New(relay.WithBaseURL("https://api.example.com"))
defer client.Shutdown(context.Background()) //nolint:errcheck
resp, err := client.Execute(client.Get("/users/1"))
if err != nil {
fmt.Println("transport error:", err)
return
}
// Convert a non-2xx response into a Go error.
if httpErr := resp.AsHTTPError(); httpErr != nil {
fmt.Printf("HTTP %d: %s\n", httpErr.StatusCode, httpErr.Body)
return
}
fmt.Println("body:", resp.String())
}
Use relay.IsHTTPError when the error may already be wrapped in an error chain:
package main
import (
"context"
"fmt"
relay "github.com/jhonsferg/relay"
)
func handleErr(err error) {
if httpErr, ok := relay.IsHTTPError(err); ok {
switch {
case httpErr.StatusCode == 401:
fmt.Println("unauthorised - refresh credentials")
case httpErr.StatusCode == 404:
fmt.Println("resource not found")
case httpErr.StatusCode >= 500:
fmt.Println("server error - retry later")
}
return
}
fmt.Println("non-HTTP error:", err)
}
func main() {
client := relay.New(relay.WithBaseURL("https://api.example.com"))
defer client.Shutdown(context.Background()) //nolint:errcheck
resp, err := client.Execute(client.Get("/resource"))
if err == nil && resp != nil {
err = resp.AsHTTPError()
}
if err != nil {
handleErr(err)
}
}
Switch-case pattern for comprehensive error handling¶
The following switch-case pattern covers every error class relay can produce and provides a template you can copy into production code:
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
relay "github.com/jhonsferg/relay"
)
// handleRelayError translates a relay error into an application-level decision.
// Returns true if the caller should retry at the application layer.
func handleRelayError(ctx context.Context, err error, resp *relay.Response) (retry bool) {
if err == nil {
return false
}
switch {
case errors.Is(err, relay.ErrCircuitOpen):
slog.WarnContext(ctx, "circuit open - fast fail")
return false
case errors.Is(err, relay.ErrBulkheadFull):
slog.WarnContext(ctx, "bulkhead full - shed load")
return false
case errors.Is(err, relay.ErrMaxRetriesReached):
slog.ErrorContext(ctx, "max retries exhausted")
return false
case errors.Is(err, relay.ErrRateLimitExceeded):
slog.WarnContext(ctx, "local rate limit exceeded")
return false
case relay.IsTimeout(err):
slog.WarnContext(ctx, "request timed out")
return true // caller may wish to retry with a longer timeout
case errors.Is(err, context.Canceled):
slog.InfoContext(ctx, "request cancelled by caller")
return false
case errors.Is(err, context.DeadlineExceeded):
slog.WarnContext(ctx, "context deadline exceeded")
return false
}
if httpErr, ok := relay.IsHTTPError(err); ok {
switch {
case httpErr.StatusCode == 429:
slog.WarnContext(ctx, "rate limited by server",
"retry_after", httpErr.Body)
return true
case httpErr.StatusCode >= 500:
slog.ErrorContext(ctx, "server error", "status", httpErr.StatusCode)
return relay.IsRetryableError(err, resp)
case httpErr.StatusCode >= 400:
slog.ErrorContext(ctx, "client error", "status", httpErr.StatusCode)
return false // 4xx is permanent
}
}
slog.ErrorContext(ctx, "unknown error", "err", err)
return false
}
func main() {
client := relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithTimeout(5*time.Second),
)
defer client.Shutdown(context.Background()) //nolint:errcheck
ctx := context.Background()
resp, err := client.Execute(client.Get("/orders").WithContext(ctx))
if handleRelayError(ctx, err, resp) {
fmt.Println("application layer may retry")
}
}
Wrapping relay errors with additional context¶
Wrap relay errors just like any other Go error using fmt.Errorf with %w. The wrapped sentinel remains reachable via errors.Is:
package main
import (
"context"
"errors"
"fmt"
relay "github.com/jhonsferg/relay"
)
type OrderError struct {
OrderID string
Cause error
}
func (e *OrderError) Error() string {
return fmt.Sprintf("order %s: %v", e.OrderID, e.Cause)
}
func (e *OrderError) Unwrap() error { return e.Cause }
func fetchOrder(client *relay.Client, id string) error {
_, err := client.Execute(client.Get("/orders/" + id))
if err != nil {
return &OrderError{OrderID: id, Cause: err}
}
return nil
}
func main() {
client := relay.New(relay.WithBaseURL("https://api.example.com"))
defer client.Shutdown(context.Background()) //nolint:errcheck
err := fetchOrder(client, "ord-123")
if err != nil {
fmt.Println(err)
// Sentinel errors are still reachable through the wrapper.
if errors.Is(err, relay.ErrCircuitOpen) {
fmt.Println("(circuit was open)")
}
}
}
Custom error handlers via OnError hook¶
The WithOnErrorHook option lets you centralise error side-effects (logging, metrics, alerting) in one place rather than repeating them at every call site:
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
relay "github.com/jhonsferg/relay"
)
func newInstrumentedClient() *relay.Client {
return relay.New(
relay.WithBaseURL("https://api.example.com"),
relay.WithTimeout(5*time.Second),
relay.WithOnErrorHook(func(ctx context.Context, req *relay.Request, err error) {
attrs := []any{
"method", req.Method(),
"url", req.URL(),
}
switch {
case errors.Is(err, relay.ErrCircuitOpen):
attrs = append(attrs, "class", "circuit_open")
case errors.Is(err, relay.ErrBulkheadFull):
attrs = append(attrs, "class", "bulkhead_full")
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, "http error", attrs...)
}),
)
}
func main() {
client := newInstrumentedClient()
defer client.Shutdown(context.Background()) //nolint:errcheck
if _, err := client.Execute(client.Get("/users")); err != nil {
// The hook already logged the error; just propagate.
fmt.Println("request failed:", err)
}
}
Warning
OnErrorHookfires for every error, including transient ones during retries. If you are tracking "final" errors only, pair the hook with a check forrelay.ErrMaxRetriesReachedor use the hook solely for metrics so that retried-and-recovered calls are not incorrectly counted as failures.