fail → next
failnext is a Go library for client-side failover across 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.
- HTTP 200 with a JSON-RPC error? Let the app retry without that provider.
- 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 is protocol-neutral. It integrates with Go's http.Client as an http.RoundTripper.
failnext handles how requests move between providers, not what those requests mean. It selects configured providers and can fail over on transport errors or selected HTTP status codes.
It does not inspect JSON-RPC response bodies or trigger failover based on JSON-RPC errors. Your application decides which providers are suitable, which operations may safely fail over, and when to retry after an application-level error.
If your application retries after an application-level error, it can use WithExcludedEndpoints to skip the provider that returned the error.
failnext is failover, not load balancing.
The project started as rcpx in February 2026 and was later renamed to failnext as part of a larger redesign.
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.
go get github.com/yermakovsa/failnextThe module requires Go 1.24.
failnext follows semantic versioning and maintains backward compatibility for its public API across v1 releases.
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;
- you may need to retry a request without a provider that returned a JSON-RPC error;
- some follow-up requests should try a previously used provider first.
failnext plugs directly into http.Client, including go-ethereum over HTTP JSON-RPC.
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.
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.
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.
If your application knows a provider is unavailable, degraded, lagging, or disabled, use Eligible to skip it:
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.
An HTTP 200 response can still contain a JSON-RPC error. failnext returns that response normally; it does not inspect the JSON-RPC payload or decide whether to try another provider.
If your application decides to try again, exclude the provider that returned the JSON-RPC error from the next request:
retryCtx := failnext.WithExcludedEndpoints(ctx, rejectedEndpoint)
result, err := call(retryCtx)Exclusion affects endpoint selection for that request only. It does not change failover permission, request replayability, cooldown, or failover trigger rules.
See examples/goethereum/exclude-endpoint.
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 and not excluded, 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;
- request-scoped exclusion;
- 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.
If every POST request sent through this transport is safe to fail over, configure PermissionPolicy once instead of calling WithFailoverAllowed for each request:
tr, err := failnext.New(failnext.Config{
Endpoints: endpoints,
PermissionPolicy: func(req *http.Request) failnext.Permission {
if req.Method == http.MethodPost {
return failnext.PermissionAllow
}
return failnext.PermissionDefer
},
})POST is not inherently safe to fail over. Use this policy only when your application knows that every POST request sent through this transport is safe to repeat.
A request-scoped allow or deny takes precedence over PermissionPolicy, so callers can override this default for a specific request.
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.
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:
- which provider should be tried;
- whether the request is allowed to fail over;
- whether the failure actually triggers failover;
- whether the request can be replayed for another attempt.
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, request-scoped exclusion, preference, events, and attempt errors.
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.
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:
GETandHEADare allowed to fail over;- other HTTP methods are denied unless the application explicitly allows failover or a
PermissionPolicyallows 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)Use PermissionPolicy when failover permission follows a rule that can be applied to every request. The policy receives the *http.Request, so it can decide based on request properties such as the HTTP method.
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.
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.
Applications can mark configured providers as ineligible 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.
Use WithExcludedEndpoints to exclude configured endpoints from a request:
ctx := failnext.WithExcludedEndpoints(parent, "provider-a")Excluded endpoints are not considered for that request. Repeated calls add to the existing exclusions:
ctx = failnext.WithExcludedEndpoints(ctx, "provider-a")
ctx = failnext.WithExcludedEndpoints(ctx, "provider-b")
// provider-a and provider-b are both excludedExclusion affects endpoint selection only for requests made with that context. It does not change failover permission, request replayability, cooldown, or failover trigger rules. Preference does not override exclusion.
If an excluded endpoint ID is unknown, failnext returns an error matching failnext.ErrUnknownEndpoint before trying any provider.
A request can specify one preferred endpoint:
ctx := failnext.WithPreferredEndpoint(parent, "backup")If the preferred endpoint exists, is eligible, and is not excluded, failnext tries it first.
The remaining providers keep their configured order.
Preference does not override:
- eligibility;
- request-scoped exclusion;
- 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 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.
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.
ErrNoUsableEndpoint is returned when endpoint selection leaves no usable provider to try.
Examples include:
- every provider is excluded by request-scoped exclusion;
- every provider is ineligible;
- 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 or excluded 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.
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 duration=%s err=%v",
event.Kind,
event.Endpoint,
event.Attempt,
event.StatusCode,
event.Duration,
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. |
Event.Duration is meaningful only for EventAttempt. It reports how long the endpoint attempt took, from when failnext hands the request to the underlying HTTP transport until that transport returns a response or error.
It does not include failnext processing before or after the attempt, or time spent reading the response body later.
For other event kinds, Duration is zero.
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.
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,
})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.
Changing providers can also change the meaning of request state tied to a specific host or URL.
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.
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.
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.
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.
The repository includes go-ethereum examples for the main integration patterns:
examples/goethereum/basic-failover: usefailnextthroughrpc.WithHTTPClientand allow a JSON-RPC read to fail over.examples/goethereum/conservative-write: keep a write from failing over to another provider.examples/goethereum/error-inspection: inspect failed provider attempts withFailoverError.examples/goethereum/exclude-endpoint: try the request again without a provider that returned a JSON-RPC error.examples/goethereum/preferred-endpoint: try the provider that handled a previous request first for a follow-up request, without pinning to it.examples/goethereum/skip-ineligible: skip providers the application marks as unsuitable.
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-Afterscheduling; - 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.
MIT. See LICENSE.