failnext

package module
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 25, 2026 License: MIT Imports: 9 Imported by: 0

README

failnext

fail → next

Client-side failover for Go applications with a small, fixed set of HTTP or RPC providers.

  • Primary RPC unavailable? Try the next provider.
  • One RPC is lagging or degraded? Skip any provider your application decides not to use.
  • Want reads to fail over, but not writes? Keep writes from failing over to another provider.
  • Reading after a write? Try the provider that handled the write first.
  • Everything failed? See which providers were tried and what went wrong.

A primary use case is go-ethereum over HTTP JSON-RPC, but failnext itself is protocol-neutral. It plugs into http.Client as an http.RoundTripper.

failnext handles the mechanics of trying providers in order. Your application decides which operations may safely continue to another provider; failnext does not parse JSON-RPC or make that decision for you.

failnext is failover, not load balancing.

Quick start

Configure your providers in priority order and use failnext underneath go-ethereum through rpc.WithHTTPClient:

endpoints := []failnext.Endpoint{
	{ID: "primary", URL: "https://provider-a.example/rpc"},
	{ID: "backup", URL: "https://provider-b.example/rpc"},
}

tr, err := failnext.New(failnext.Config{
	Endpoints: endpoints,
})
if err != nil {
	return err
}

httpClient := &http.Client{Transport: tr}

rpcClient, err := rpc.DialOptions(
	context.Background(),
	endpoints[0].URL,
	rpc.WithHTTPClient(httpClient),
)
if err != nil {
	return err
}
defer rpcClient.Close()

eth := ethclient.NewClient(rpcClient)

// JSON-RPC reads use HTTP POST, so explicitly allow this read to fail over.
ctx := failnext.WithFailoverAllowed(context.Background())

blockNumber, err := eth.BlockNumber(ctx)

If the primary fails in a way that triggers failover, failnext can try the next provider.

Ethereum JSON-RPC reads use HTTP POST, so failnext cannot tell from the HTTP method whether a read may safely be sent to another provider. It does not inspect JSON-RPC method names; your application makes that decision explicitly.

See examples/goethereum/basic-failover for a complete runnable example.

Installation

go get github.com/yermakovsa/failnext

The module requires Go 1.24.

failnext was previously named rcpx.

When failnext fits

failnext fits best when:

  • you have a small set of known HTTP or RPC providers;
  • those providers have a clear priority, such as primary and backup;
  • you want failover to happen inside your Go application;
  • your application decides which operations may safely fail over;
  • your application may need to skip providers it considers unavailable, lagging, or degraded;
  • some follow-up requests should try a previously used provider first.

failnext plugs directly into http.Client, including go-ethereum over HTTP JSON-RPC.

When a proxy or gateway may fit better

A proxy or gateway may be a better fit when you want provider failover and routing to be shared across multiple applications rather than handled inside each Go client.

For example, when you need:

  • one shared RPC endpoint for many applications;
  • service discovery or dynamic load balancing;
  • active checks for provider health or blockchain lag;
  • centralized caching or routing rules;
  • provider selection to happen outside application code;
  • WebSocket failover.

failnext is intentionally smaller. It keeps failover inside your Go application and works with a small, fixed set of configured HTTP or RPC providers.

Common patterns

Allow a read to fail over

By default, failnext allows HTTP GET and HEAD requests to continue to another provider.

JSON-RPC reads normally use POST, so your application must explicitly allow a read to fail over when it knows that operation is safe to repeat:

ctx := failnext.WithFailoverAllowed(parent)

blockNumber, err := eth.BlockNumber(ctx)

Allowing failover only gives failnext permission to try another provider. It does not make the request replayable, make another provider eligible, bypass cooldown, or change which failures trigger failover.

Keep writes from failing over

Some operations should not be sent to another provider after the first attempt fails.

Even if failover is allowed by a parent context, you can explicitly disable it for a write:

allowedCtx := failnext.WithFailoverAllowed(ctx)
writeCtx := failnext.WithFailoverDenied(allowedCtx)

err := eth.SendTransaction(writeCtx, tx)

With failover denied, failnext will not try another provider for that operation.

failnext does not inspect JSON-RPC method names or decide which operations are safe to repeat. Your application makes that decision.

A replayable HTTP request is not necessarily an operation that should be sent to another provider.

See examples/goethereum/conservative-write.

Skip providers your application marks as unsuitable

If your application knows a provider should not be used, for example because it is lagging, degraded, or disabled, exclude it with Eligible:

tr, err := failnext.New(failnext.Config{
	Endpoints: endpoints,
	Eligible: func(id failnext.EndpointID) bool {
		return eligible(id)
	},
})

failnext does not detect provider health, chain lag, or stale blockchain state. Your application owns that decision.

For each request, failnext checks eligibility once per endpoint and keeps those decisions fixed for that request.

See examples/goethereum/skip-ineligible.

Prefer a provider for a follow-up request

If one provider handled an earlier request, you can ask failnext to try that provider first for a follow-up request:

ctx := failnext.WithFailoverAllowed(parent)
ctx = failnext.WithPreferredEndpoint(ctx, preferred)

If that provider is eligible, failnext tries it first. The order of the remaining providers stays the same.

Preference only changes which provider is tried first. It does not pin the request to that provider or guarantee cross-provider consistency.

It does not bypass:

  • eligibility;
  • cooldown;
  • permission to fail over;
  • request replayability;
  • failover trigger rules.

If your application depends on provider-local state, pending state, sessions, or read-after-write behavior, it still needs to handle those consistency requirements itself.

See examples/goethereum/preferred-endpoint.

Inspect failed providers

If one or more provider attempts fail and the request ends without an HTTP response to return, FailoverError records those failed attempts in order:

var fe *failnext.FailoverError
if errors.As(err, &fe) {
	for _, attempt := range fe.Attempts {
		log.Printf("endpoint=%s err=%v", attempt.Endpoint, attempt.Err)
	}
}

The terminal cause remains available through normal Go error unwrapping.

For more detailed visibility, Config.OnEvent can report provider attempts, cooldown skips, replay failures, and the final result.

See examples/goethereum/error-inspection.

How failover works

The sections below define the exact rules behind the common patterns above.

A later provider is not tried just because another provider is available.

Four decisions are separate:

  1. which provider should be tried;
  2. whether the request is allowed to fail over;
  3. whether the failure actually triggers failover;
  4. whether the request can be replayed for another attempt.
Endpoints and priority

Each provider has an ID and a complete HTTP destination:

Endpoints: []failnext.Endpoint{
	{ID: "primary", URL: "https://provider-a.example/rpc?key=..."},
	{ID: "backup", URL: "https://provider-b.example/rpc?key=..."},
}

Providers are normally tried in the order they are configured.

For each attempt, failnext uses the complete URL of the selected provider.

Configured endpoint URLs are not base URLs. failnext does not:

  • join paths;
  • merge query strings;
  • perform service discovery.

Endpoint IDs are used by eligibility, preference, events, and attempt errors.

Failover triggers

A transport failure can trigger failover while the request context is still active, as long as there is no usable HTTP response to return.

The built-in HTTP status triggers are:

  • 502 Bad Gateway;
  • 503 Service Unavailable;
  • 504 Gateway Timeout.

Applications may add other HTTP status codes:

tr, err := failnext.New(failnext.Config{
	Endpoints: endpoints,
	AdditionalTriggerStatusCodes: []int{
		http.StatusTooManyRequests,
	},
})

Additional status codes extend the built-in set.

An HTTP response that does not match a failover trigger is returned without trying another provider, even if the response body contains an RPC or application error.

For example, an HTTP 200 response containing a JSON-RPC error object does not trigger failover.

failnext does not inspect response payloads.

Permission to fail over

A failure can trigger failover, but the request must also be allowed to continue to another provider.

The permission order is:

request-scoped allow or deny
        ↓
PermissionPolicy
        ↓
GET / HEAD inference
        ↓
deny

By default:

  • GET and HEAD are allowed to fail over;
  • other HTTP methods are denied unless the application explicitly allows failover or a PermissionPolicy allows it.

Use request-scoped permission when your code knows that an operation may safely fail over:

ctx := failnext.WithFailoverAllowed(parent)

An inherited allow can be overridden when a specific operation should not fail over:

ctx := failnext.WithFailoverDenied(parent)

When configured, PermissionPolicy receives the *http.Request.

An explicit request-scoped allow or deny takes precedence over the policy.

Permission only controls whether failnext may continue to another provider. It does not:

  • make a request body replayable;
  • make an endpoint eligible;
  • bypass cooldown;
  • change which failures trigger failover.
Request bodies and replay

The first provider can use the original request body, even if that body cannot be replayed.

If failnext needs to try another provider, a request with a body needs a fresh copy from http.Request.GetBody:

no body
    -> another provider can be tried

body + working GetBody
    -> another provider can be tried

body + no GetBody
    -> first provider can be tried
    -> another provider cannot be tried

failnext does not buffer request bodies to make them replayable.

Permission and replayability are separate:

  • allowing an operation to fail over does not make its request body replayable;
  • having a replayable body does not make the operation safe to repeat.

Endpoint selection

Eligibility

Applications can exclude configured providers with Eligible:

tr, err := failnext.New(failnext.Config{
	Endpoints: endpoints,
	Eligible: func(id failnext.EndpointID) bool {
		return !disabled(id)
	},
})

For each request, failnext checks eligibility once per endpoint. Those decisions stay fixed for the lifetime of that request.

Preferred endpoint

A request can specify one preferred endpoint:

ctx := failnext.WithPreferredEndpoint(parent, "backup")

If the preferred endpoint exists and is eligible, failnext tries it first.

The remaining providers keep their configured order.

Preference does not override:

  • eligibility;
  • cooldown;
  • permission to fail over;
  • request replayability;
  • failover trigger rules.

If the preferred endpoint is unknown, failnext returns an error matching failnext.ErrUnknownEndpoint before any provider is tried.

Cooldown

Cooldown temporarily skips a provider after a configured number of qualifying failures.

It is enabled by default with:

  • 3 consecutive cooldown failures;
  • a 30-second cooldown duration.

Configure it with:

Cooldown: failnext.CooldownConfig{
	Threshold: 2,
	Duration:  time.Minute,
},

Or disable it:

Cooldown: failnext.CooldownConfig{
	Disabled: true,
},

Not every failure that triggers failover counts toward cooldown.

Cooldown failures are:

  • transport failures while the request context is still active and no usable HTTP response is available;
  • HTTP 502;
  • HTTP 503;
  • HTTP 504.

Other HTTP responses reset the consecutive cooldown failure streak, even if an additional status code is configured to trigger failover.

For example, if you add 429 Too Many Requests as a failover trigger, a 429 can cause failover, but it does not count as a cooldown failure.

Eligibility and cooldown are separate:

  • eligibility stays fixed for the lifetime of a request;
  • cooldown is checked when a provider is about to be tried.

Cooldown is not a health checker.

There are no:

  • active probes;
  • half-open states;
  • background workers;
  • adaptive retry scheduling.

Results and errors

Trigger responses and later failures

If a provider returns an HTTP response that triggers failover, failnext can keep that response while trying another provider.

If another provider later returns an HTTP response, the newer response replaces the earlier one.

If the later provider fails with a transport error, the earlier HTTP response can still be returned.

For example:

primary -> HTTP 503
backup  -> transport error

result  -> primary's HTTP 503 response

If GetBody fails while preparing a request for another provider, that provider is not tried. A previously retained HTTP response can still be returned.

Request cancellation or deadline expiration takes priority over a retained response.

Responses that remain internal to failnext are closed when they are discarded or replaced.

Once a response is returned, its body belongs to the caller and should be closed normally.

Errors

ErrNoUsableEndpoint is returned when endpoint selection leaves no usable provider to try.

Examples include:

  • every provider is excluded by eligibility;
  • every provider is cooling before the first attempt.

ErrUnknownEndpoint is returned when a request refers to an endpoint ID that does not exist, such as an unknown preferred endpoint.

FailoverError is used when:

  • one or more provider attempts fail without producing a usable HTTP response; and
  • there is no HTTP response available to return.

Its Attempts slice records those failed provider attempts in order.

The terminal cause remains available through normal Go error unwrapping:

var fe *failnext.FailoverError
if errors.As(err, &fe) {
	for _, attempt := range fe.Attempts {
		log.Printf("endpoint=%s err=%v", attempt.Endpoint, attempt.Err)
	}
}

if errors.Is(err, failnext.ErrNoUsableEndpoint) {
	log.Printf("no provider could be tried")
}

if errors.Is(err, failnext.ErrUnknownEndpoint) {
	log.Printf("request referred to an unknown endpoint")
}

Cancellation and deadline expiration remain normal context errors and are not wrapped in FailoverError.

Observability and transport composition

Events

Config.OnEvent provides a synchronous observation hook:

OnEvent: func(ctx context.Context, event failnext.Event) {
	log.Printf(
		"kind=%d endpoint=%s attempt=%d status=%d err=%v",
		event.Kind,
		event.Endpoint,
		event.Attempt,
		event.StatusCode,
		event.Err,
	)
},

The event kinds are:

Event Meaning
EventAttempt A provider attempt completed.
EventCooldownSkip A provider was skipped because it is currently cooling down.
EventReplayError Another provider could not be tried because GetBody failed.
EventResult RoundTrip is about to return the final response or error.

Events for one request are delivered in causal order.

Different requests may invoke the callback concurrently, so shared application state must be protected.

OnEvent is synchronous. The callback should return promptly.

failnext does not recover panics from OnEvent.

Base transport

The configured Base transport handles every provider attempt.

If Base is nil, http.DefaultTransport is used.

Use Base for behavior that needs to run separately for each provider attempt, such as:

  • endpoint-specific authentication;
  • host- or path-bound signing;
  • tracing;
  • custom networking behavior.
tr, err := failnext.New(failnext.Config{
	Endpoints: endpoints,
	Base:      customTransport,
})
Concurrency and CloseIdleConnections

A constructed Transport is intended for concurrent reuse.

Custom base transports and application callbacks must satisfy their own concurrency requirements.

PermissionPolicy, Eligible, and OnEvent may run concurrently for different requests.

Transport.CloseIdleConnections forwards to the base transport when the base supports that operation, so http.Client.CloseIdleConnections composes normally.

Destination-sensitive request state

Changing providers can also change the meaning of request state tied to a specific host or URL.

Request.Host

failnext uses these rules:

Host == ""
    -> remains empty

Host == original URL.Host
    -> treated as URL-derived
    -> follows the selected endpoint URL.Host

Host != original URL.Host
    -> treated as custom
    -> preserved

The comparison is exact.

If a custom Host differs from the original URL.Host, failnext preserves it when trying another provider.

One ambiguity remains. If your application intentionally sets a custom Host to exactly the same value as the original URL.Host, failnext cannot distinguish that custom value from the normal URL-derived host value. In that case, the Host follows the selected provider.

If your application needs different Host behavior, Config.Base middleware can reapply the intended value for each provider attempt.

Other destination-sensitive state

Host is only one example.

These values may also need application-specific handling:

  • credentials;
  • host- or path-bound signatures;
  • provider-specific headers;
  • cookies;
  • session state;
  • other destination-bound metadata.

failnext does not automatically regenerate, rewrite, or validate those values when it switches to another provider.

Redirects

Redirect handling belongs to http.Client.

If http.Client follows a redirect, the redirected request starts a new failnext request. It is not another provider attempt in the current failover sequence.

Generic http.Client usage

go-ethereum is a primary use case, but the transport itself is protocol-neutral.

Use failnext with a normal http.Client:

package main

import (
	"log"
	"net/http"

	"github.com/yermakovsa/failnext"
)

func main() {
	tr, err := failnext.New(failnext.Config{
		Endpoints: []failnext.Endpoint{
			{ID: "primary", URL: "https://service-a.example/api"},
			{ID: "backup", URL: "https://service-b.example/api"},
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	client := &http.Client{Transport: tr}

	req, err := http.NewRequest(
		http.MethodGet,
		"https://service-a.example/api",
		nil,
	)
	if err != nil {
		log.Fatal(err)
	}

	resp, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()
}

GET requests are allowed to fail over by default, as long as the other failover conditions are satisfied.

Providers are tried in their configured priority order.

Examples

The repository includes go-ethereum examples for the main integration patterns:

What failnext does not do

failnext is a focused HTTP failover transport, not a general resilience or RPC routing framework.

It does not provide:

  • load balancing or service discovery;
  • active health checks;
  • automatic blockchain lag or stale-state detection;
  • general-purpose retry, backoff, or Retry-After scheduling;
  • application-protocol parsing, including JSON-RPC payload and error interpretation;
  • buffering request bodies to make them replayable;
  • a general signing, authentication, or header-rewrite framework;
  • hard provider pinning;
  • cross-provider state consistency;
  • base-URL path or query composition;
  • WebSocket failover;
  • Ethereum transaction, nonce, or pending-state management.

If your application depends on provider-local state, sessions, pending state, or read-after-write behavior, it must handle those requirements itself.

License

MIT. See LICENSE.

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

Examples

Constants

This section is empty.

Variables

View Source
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

func WithFailoverAllowed(ctx context.Context) context.Context

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

func WithFailoverDenied(ctx context.Context) context.Context

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 EndpointID

type EndpointID string

EndpointID identifies a configured endpoint.

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

func New(cfg Config) (*Transport, error)

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.

func (*Transport) RoundTrip

func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip executes req using the configured endpoint failover rules.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL