fix(gost): restore single-node bypass and preserve FailFilter backoff

Address reviewer feedback from PR #14 fix:

1. Single-node case: Bypass selector/FailFilter to ensure availability.
   This matches upstream go-gost/x behavior - single nodes should always
   be attempted regardless of recent failures.

2. Multi-node case: Preserve FailFilter's backoff contract. When all nodes
   are marked as failed, return nil to signal 'no healthy nodes' rather
   than falling back to a known-bad node. This prevents hammering unhealthy
   nodes and respects the failTimeout window.

The handler's retry loop with ExcludeNodes context handles the multi-node
failover properly - this change ensures hop.Select() provides correct
information about node health status.

Fixes intermittent forwarding failures introduced by #14.
This commit is contained in:
root
2026-02-04 07:16:34 +00:00
parent 0273bc6921
commit 3e046fc80e
2 changed files with 24 additions and 8 deletions
+19 -4
View File
@@ -201,11 +201,26 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
return nodes[0]
}
// Always go through selector for proper FailFilter evaluation,
// even when there's only one node. This ensures failed nodes
// can be filtered out properly.
// For single-node case: bypass selector/FailFilter to ensure availability.
// The marker system still works for metrics, but we don't block requests
// based on recent failures - the connection will be attempted regardless.
// This matches upstream go-gost/x behavior.
if len(nodes) == 1 {
return nodes[0]
}
// Multi-node case: use selector with FailFilter for proper failover.
// FailFilter will exclude recently-failed nodes, allowing traffic to
// be routed to healthy alternatives.
if s := p.options.selector; s != nil {
return s.Select(ctx, nodes...)
if node := s.Select(ctx, nodes...); node != nil {
return node
}
// All nodes filtered out by FailFilter - all are marked as failed.
// Return nil to signal "no healthy nodes available" to the caller.
// The handler's retry loop will handle this appropriately.
log.Debugf("all %d nodes filtered out by FailFilter, no healthy nodes available", len(nodes))
return nil
}
// Fallback: return first node if no selector configured
+5 -4
View File
@@ -24,11 +24,12 @@ func FailFilter[T any](maxFails int, timeout time.Duration) selector.Filter[T] {
}
// Filter filters dead objects.
// Note: We intentionally do NOT skip filtering when len(vs) <= 1.
// This ensures that even a single dead node gets filtered out,
// allowing the caller to know that no healthy nodes are available
// and potentially trigger failover behavior.
// For single-node case, skip filtering to ensure availability (matches upstream).
// For multi-node case, filter out failed nodes to enable failover.
func (f *failFilter[T]) Filter(ctx context.Context, vs ...T) []T {
if len(vs) <= 1 {
return vs
}
var l []T
for _, v := range vs {
maxFails := f.maxFails