Documentation
¶
Overview ¶
Package failnext provides an HTTP failover RoundTripper for applications with a small, ordered set of fixed endpoints.
A Transport normally tries configured endpoints in priority order. Applications can exclude endpoints with Eligible or prefer one endpoint for a request. Cooldown can temporarily skip endpoints after qualifying failures.
Permission to fail over is separate from endpoint selection. Applications can allow or deny failover through request context or PermissionPolicy. Trying another endpoint with a request body requires Request.GetBody; failnext does not buffer request bodies to make them replayable.
failnext operates at the HTTP transport layer. It does not inspect protocol payloads or provide load balancing, active health checks, general-purpose retry scheduling, or general-purpose rewriting of destination-specific request state.
Use a Transport as http.Client.Transport. Base handles each provider attempt.
Index ¶
- Variables
- func WithFailoverAllowed(ctx context.Context) context.Context
- func WithFailoverDenied(ctx context.Context) context.Context
- func WithPreferredEndpoint(ctx context.Context, id EndpointID) context.Context
- type AttemptError
- type Config
- type CooldownConfig
- type Endpoint
- type EndpointID
- type Event
- type EventKind
- type FailoverError
- type Permission
- type Transport
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNoUsableEndpoint indicates that endpoint selection left no usable endpoint to try. ErrNoUsableEndpoint = errors.New("failnext: no usable endpoint") // ErrUnknownEndpoint indicates that a request refers to an endpoint ID that // is not configured on the Transport. ErrUnknownEndpoint = errors.New("failnext: unknown endpoint") )
Functions ¶
func WithFailoverAllowed ¶
WithFailoverAllowed returns a context that explicitly permits failover to another endpoint. Other failover conditions still apply.
Example ¶
package main
import (
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/yermakovsa/failnext"
)
func main() {
tr, err := failnext.New(failnext.Config{
Endpoints: []failnext.Endpoint{
{ID: "primary", URL: "https://primary.example/rpc"},
{ID: "backup", URL: "https://backup.example/rpc"},
},
Base: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case "primary.example":
return nil, errors.New("primary unavailable")
case "backup.example":
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(
`{"jsonrpc":"2.0","id":1,"result":"0x2a"}`,
)),
Header: make(http.Header),
}, nil
default:
return nil, fmt.Errorf("unexpected destination %s", req.URL)
}
}),
})
if err != nil {
panic(err)
}
client := &http.Client{Transport: tr}
req, err := http.NewRequest(
http.MethodPost,
"https://primary.example/rpc",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}`),
)
if err != nil {
panic(err)
}
// This RPC operation is a read, so it is safe to send to another provider.
req = req.WithContext(failnext.WithFailoverAllowed(req.Context()))
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
Output: {"jsonrpc":"2.0","id":1,"result":"0x2a"}
func WithFailoverDenied ¶
WithFailoverDenied returns a context that explicitly prevents the request from failing over. A later failnext permission on a derived context takes precedence.
func WithPreferredEndpoint ¶
func WithPreferredEndpoint(ctx context.Context, id EndpointID) context.Context
WithPreferredEndpoint returns a context that prefers id as the first endpoint to try. Preference does not override eligibility or cooldown and does not pin the request to that endpoint
Example ¶
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/yermakovsa/failnext"
)
func main() {
type resultRecorderKey struct{}
type resultRecorder struct {
endpoint failnext.EndpointID
}
var calls []string
tr, err := failnext.New(failnext.Config{
Endpoints: []failnext.Endpoint{
{ID: "primary", URL: "https://primary.example/api"},
{ID: "backup", URL: "https://backup.example/api"},
},
Base: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
calls = append(calls, req.URL.Host)
switch req.URL.Host {
case "primary.example":
return nil, errors.New("primary unavailable")
case "backup.example":
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("ok")),
Header: make(http.Header),
}, nil
default:
return nil, fmt.Errorf("unexpected destination %s", req.URL)
}
}),
OnEvent: func(ctx context.Context, event failnext.Event) {
if event.Kind != failnext.EventResult || event.Endpoint == "" {
return
}
if recorder, ok := ctx.Value(resultRecorderKey{}).(*resultRecorder); ok {
recorder.endpoint = event.Endpoint
}
},
})
if err != nil {
panic(err)
}
client := &http.Client{Transport: tr}
// Record which provider produced the first result.
recorder := &resultRecorder{}
firstCtx := context.WithValue(
context.Background(),
resultRecorderKey{},
recorder,
)
first, err := http.NewRequestWithContext(
firstCtx,
http.MethodGet,
"https://primary.example/api",
nil,
)
if err != nil {
panic(err)
}
resp, err := client.Do(first)
if err != nil {
panic(err)
}
resp.Body.Close()
if recorder.endpoint == "" {
panic("request completed without a result endpoint")
}
// Prefer that provider for the related request.
secondCtx := failnext.WithPreferredEndpoint(
context.Background(),
recorder.endpoint,
)
second, err := http.NewRequestWithContext(
secondCtx,
http.MethodGet,
"https://primary.example/api",
nil,
)
if err != nil {
panic(err)
}
resp, err = client.Do(second)
if err != nil {
panic(err)
}
resp.Body.Close()
fmt.Printf(
"preferred=%s calls=%s\n",
recorder.endpoint,
strings.Join(calls, ","),
)
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
Output: preferred=backup calls=primary.example,backup.example,backup.example
Types ¶
type AttemptError ¶
type AttemptError struct {
// Endpoint identifies the endpoint that was tried.
Endpoint EndpointID
// Err is the error associated with the attempt.
Err error
}
AttemptError records an endpoint attempt that failed without producing a usable HTTP response.
type Config ¶
type Config struct {
// Endpoints is the ordered set of configured HTTP destinations.
Endpoints []Endpoint
// Base handles each endpoint attempt. If nil, http.DefaultTransport is used.
Base http.RoundTripper
// PermissionPolicy decides whether a request may fail over when no explicit
// request-scoped permission is set.
//
// It is called at most once per RoundTrip. PermissionDefer falls back to the
// default HTTP method rules.
//
// PermissionPolicy may be called concurrently for different requests. It
// should return promptly and must not mutate the request or consume, close,
// or replace Request.Body.
PermissionPolicy func(*http.Request) Permission
// Eligible reports whether an endpoint may be used for a request. If nil,
// all configured endpoints are eligible.
//
// It is called once per endpoint per request, and each result stays fixed
// for the lifetime of that request.
//
// Eligible may be called concurrently for different requests and should
// return promptly.
Eligible func(EndpointID) bool
// Cooldown configures passive endpoint cooldown after qualifying failures.
// The zero value enables cooldown with default settings.
Cooldown CooldownConfig
// AdditionalTriggerStatusCodes extends the built-in failover status codes
// 502, 503, and 504.
//
// Additional trigger statuses cause failover consideration but do not
// automatically count as cooldown failures.
AdditionalTriggerStatusCodes []int
// OnEvent, if non-nil, is called synchronously for observability events.
//
// Events for one request are delivered in causal order. Different requests
// may invoke OnEvent concurrently, so shared mutable state must be protected.
// The callback should return promptly.
//
// failnext does not recover panics from OnEvent.
OnEvent func(context.Context, Event)
}
Config configures a Transport.
Endpoints define the fixed priority order. Each endpoint URL is a complete destination; failnext does not join request paths or merge query strings.
Example (Eligibility) ¶
package main
import (
"fmt"
"io"
"net/http"
"strings"
"github.com/yermakovsa/failnext"
)
func main() {
// The application provides a read-only snapshot of provider availability.
available := map[failnext.EndpointID]bool{
"primary": false,
"backup": true,
}
tr, err := failnext.New(failnext.Config{
Endpoints: []failnext.Endpoint{
{ID: "primary", URL: "https://primary.example/api"},
{ID: "backup", URL: "https://backup.example/api"},
},
Eligible: func(id failnext.EndpointID) bool {
return available[id]
},
Base: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host != "backup.example" {
return nil, fmt.Errorf("unexpected destination %s", req.URL)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("served by backup")),
Header: make(http.Header),
}, nil
}),
})
if err != nil {
panic(err)
}
client := &http.Client{Transport: tr}
resp, err := client.Get("https://primary.example/api")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
Output: served by backup
type CooldownConfig ¶
type CooldownConfig struct {
// Disabled disables cooldown. It cannot be combined with Threshold or Duration.
Disabled bool
// Threshold is the number of consecutive qualifying failures required
// before an endpoint enters cooldown. Zero uses the default of 3 unless
// Disabled is true.
Threshold int
// Duration is how long an endpoint remains in cooldown. Zero uses the
// default of 30 seconds unless Disabled is true.
Duration time.Duration
}
CooldownConfig configures passive endpoint cooldown.
The zero value enables cooldown with a threshold of 3 qualifying failures and a duration of 30 seconds.
type Endpoint ¶
type Endpoint struct {
// ID is the application-visible identity of the endpoint.
ID EndpointID
// URL is the complete destination used when this endpoint is tried.
// It must be an absolute HTTP or HTTPS URL with a host and no fragment.
// failnext does not join request paths or merge query strings with it.
URL string
}
Endpoint defines one HTTP destination.
type Event ¶
type Event struct {
// Kind identifies the event.
Kind EventKind
// Endpoint identifies the endpoint associated with the event, if any.
Endpoint EndpointID
// Attempt is the 1-based endpoint attempt number when applicable.
Attempt int
// StatusCode is the HTTP response status code when applicable.
StatusCode int
// Err is the error associated with the event, if any.
Err error
}
Event describes an observability event emitted while handling a request.
type EventKind ¶
type EventKind uint8
EventKind identifies a kind of observability event.
const ( // EventAttempt reports a completed endpoint attempt. EventAttempt EventKind = iota + 1 // EventCooldownSkip reports an endpoint skipped because it is cooling down. EventCooldownSkip // EventReplayError reports that another endpoint could not be tried because // Request.GetBody failed. EventReplayError // EventResult reports the final response or error RoundTrip is about to return. EventResult )
type FailoverError ¶
type FailoverError struct {
// Attempts contains the failed endpoint attempts in order.
Attempts []AttemptError
// contains filtered or unexported fields
}
FailoverError reports that one or more endpoint attempts failed without producing a usable HTTP response and no HTTP response is available to return.
Example ¶
package main
import (
"errors"
"fmt"
"net/http"
"github.com/yermakovsa/failnext"
)
func main() {
primaryErr := errors.New("primary unavailable")
backupErr := errors.New("backup unavailable")
tr, err := failnext.New(failnext.Config{
Endpoints: []failnext.Endpoint{
{ID: "primary", URL: "https://primary.example/api"},
{ID: "backup", URL: "https://backup.example/api"},
},
Base: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case "primary.example":
return nil, primaryErr
case "backup.example":
return nil, backupErr
default:
return nil, fmt.Errorf("unexpected destination %s", req.URL)
}
}),
})
if err != nil {
panic(err)
}
client := &http.Client{Transport: tr}
_, err = client.Get("https://primary.example/api")
var failoverErr *failnext.FailoverError
if !errors.As(err, &failoverErr) {
panic("expected FailoverError")
}
for _, attempt := range failoverErr.Attempts {
fmt.Printf("%s: %v\n", attempt.Endpoint, attempt.Err)
}
fmt.Printf("final error is backup: %v\n", errors.Is(err, backupErr))
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
Output: primary: primary unavailable backup: backup unavailable final error is backup: true
func (*FailoverError) Error ¶
func (e *FailoverError) Error() string
func (*FailoverError) Unwrap ¶
func (e *FailoverError) Unwrap() error
Unwrap returns the terminal cause.
type Permission ¶
type Permission uint8
Permission describes whether a request may fail over to another endpoint.
const ( // PermissionDefer falls back to the built-in HTTP method rules. PermissionDefer Permission = iota // PermissionAllow allows the request to fail over. PermissionAllow // PermissionDeny prevents the request from failing over. PermissionDeny )
type Transport ¶
type Transport struct {
// contains filtered or unexported fields
}
Transport is an http.RoundTripper that fails over across configured endpoints.
A Transport is intended for concurrent reuse.
func New ¶
New validates the configuration and returns a reusable Transport.
Example ¶
package main
import (
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/yermakovsa/failnext"
)
func main() {
tr, err := failnext.New(failnext.Config{
Endpoints: []failnext.Endpoint{
{ID: "primary", URL: "https://primary.example/api"},
{ID: "backup", URL: "https://backup.example/api"},
},
Base: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Host {
case "primary.example":
return nil, errors.New("primary unavailable")
case "backup.example":
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("served by backup")),
Header: make(http.Header),
}, nil
default:
return nil, fmt.Errorf("unexpected destination %s", req.URL)
}
}),
})
if err != nil {
panic(err)
}
client := &http.Client{Transport: tr}
req, err := http.NewRequest(
http.MethodGet,
"https://primary.example/api",
nil,
)
if err != nil {
panic(err)
}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
Output: served by backup
func (*Transport) CloseIdleConnections ¶
func (t *Transport) CloseIdleConnections()
CloseIdleConnections closes idle connections on the configured base transport when it supports that operation.