diff --git a/internal/enginenetx/happyeyeballs.go b/internal/enginenetx/happyeyeballs.go new file mode 100644 index 0000000000..3f2e962b1a --- /dev/null +++ b/internal/enginenetx/happyeyeballs.go @@ -0,0 +1,30 @@ +package enginenetx + +import "time" + +// happyEyeballsDelay implements an happy-eyeballs like algorithm with the +// given base delay and with the given index. The index is the attempt number +// and the first attempt should have zero as its index. +// +// The algorithm should emit 0 as the first delay, the baseDelay as the +// second delay, and then it should double the base delay at each attempt, +// until we reach the 30 seconds, after which the delay is constant. +// +// By doubling the base delay, we account for the case where there are +// actual issues inside the network. By using this algorithm, we are still +// able to overlap and pack more dialing attempts overall. +func happyEyeballsDelay(baseDelay time.Duration, idx int) time.Duration { + const cutoff = 30 * time.Second + switch { + case idx <= 0: + return 0 + case idx == 1: + return baseDelay + default: + delay := baseDelay << (idx - 1) + if delay > cutoff { + delay = cutoff + } + return delay + } +} diff --git a/internal/enginenetx/happyeyeballs_test.go b/internal/enginenetx/happyeyeballs_test.go new file mode 100644 index 0000000000..965fc4fe16 --- /dev/null +++ b/internal/enginenetx/happyeyeballs_test.go @@ -0,0 +1,41 @@ +package enginenetx + +import ( + "fmt" + "testing" + "time" +) + +func TestHappyEyeballsDelay(t *testing.T) { + type testcase struct { + idx int + expect time.Duration + } + + const delay = 900 * time.Millisecond + + cases := []testcase{ + {-1, 0}, // make sure we gracefully handle negative numbers (i.e., we don't crash) + {0, 0}, + {1, delay}, + {2, delay * 2}, + {3, delay * 4}, + {4, delay * 8}, + {5, delay * 16}, + {6, delay * 32}, + {7, 30 * time.Second}, + {8, 30 * time.Second}, + {9, 30 * time.Second}, + {10, 30 * time.Second}, + } + + for _, tc := range cases { + t.Run(fmt.Sprintf("delay=%v tc.idx=%v", delay, tc.idx), func(t *testing.T) { + got := happyEyeballsDelay(delay, tc.idx) + if got != tc.expect { + t.Fatalf("with delay=%v tc.idx=%v we got %v but expected %v", delay, tc.idx, got, tc.expect) + } + t.Logf("with delay=%v tc.idx=%v: got %v", delay, tc.idx, got) + }) + } +} diff --git a/internal/enginenetx/httpsdialernull.go b/internal/enginenetx/httpsdialernull.go index 1a26f53298..fccaba62ae 100644 --- a/internal/enginenetx/httpsdialernull.go +++ b/internal/enginenetx/httpsdialernull.go @@ -22,6 +22,12 @@ type HTTPSDialerNullPolicy struct{} var _ HTTPSDialerPolicy = &HTTPSDialerNullPolicy{} +// httpsDialerHappyEyeballsDelay is the delay after which we should start a new TCP +// connect and TLS handshake using another tactic. The standard Go library uses a 300ms +// delay for connecting. Because a TCP connect is one round trip and the TLS handshake +// is two round trips (roughly), we multiply this value by three. +const httpsDialerHappyEyeballsDelay = 900 * time.Millisecond + // LookupTactics implements HTTPSDialerPolicy. func (*HTTPSDialerNullPolicy) LookupTactics( ctx context.Context, domain, port string, reso model.Resolver) ([]*HTTPSDialerTactic, error) { @@ -30,12 +36,11 @@ func (*HTTPSDialerNullPolicy) LookupTactics( return nil, err } - const delay = 300 * time.Millisecond var tactics []*HTTPSDialerTactic for idx, addr := range addrs { tactics = append(tactics, &HTTPSDialerTactic{ Endpoint: net.JoinHostPort(addr, port), - InitialDelay: time.Duration(idx) * delay, // zero for the first dial + InitialDelay: happyEyeballsDelay(httpsDialerHappyEyeballsDelay, idx), SNI: domain, VerifyHostname: domain, })