The initial upstream exchange ignored its supplied timeout and used hardcoded client limits. Bound each attempt with a context, copy the shared client for its timeout, and share the attempt budget with UDP-to-TCP fallback. Close private connections on cancellation to interrupt reads, and exclude caller cancellation from upstream failures so parallel losers remain healthy. The normal engine pipeline never applied server.timeouts.query, allowing failover to outlive the configured query budget. Apply a hot-reloaded overall context deadline at Handle while retaining earlier caller deadlines. Add silent UDP/TCP/TLS, cancellation, fallback, failover, and reload regressions. The server inflight test assumed receiving a TCP reply meant its sent counter had already been incremented. Wait for both completion counters before asserting them, preserving the existing assertions while removing the scheduling race.
This commit is contained in:
1 parent
32b214fac0
commit
340b4eaf80
6 files changed
+233
-10
No files matched your search
@@ -461,7 +461,7 @@ func (p *Pool) clientFor(u *Upstream) *dns.Client {
|
||||
MinVersion: tls.VersionTLS12,
|
||||
NextProtos: []string{"dot"},
|
||||
},
|
||||
Dialer: &net.Dialer{Timeout: 3 * time.Second},
|
||||
Dialer: &net.Dialer{},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
case "tcp":
|
||||
@@ -475,7 +475,7 @@ func (p *Pool) clientFor(u *Upstream) *dns.Client {
|
||||
//
|
||||
// want restricts the query to the upstreams an ACL group named, by configured
|
||||
// or resolved address; an empty list means the whole pool. timeout bounds one
|
||||
// attempt.
|
||||
// attempt, including UDP-to-TCP fallback. The caller context bounds all attempts.
|
||||
func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, want []string, timeout time.Duration) (*dns.Msg, string, error) {
|
||||
st := p.state.Load()
|
||||
candidates := st.defaults
|
||||
@@ -518,6 +518,9 @@ func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, want []string, timeout
|
||||
// thing failover exists to avoid.
|
||||
var lastErr error
|
||||
for _, up := range chosen {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
msg, addr, err := p.exchangeOne(ctx, st.cfg, up, q, timeout)
|
||||
if err == nil && msg != nil && msg.Rcode != dns.RcodeServerFailure {
|
||||
return msg, addr, nil
|
||||
@@ -627,21 +630,35 @@ func (p *Pool) exchangeOne(ctx context.Context, cfg config.Upstream, up *Upstrea
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Second
|
||||
}
|
||||
client := p.clientFor(up)
|
||||
resp, rtt, err := client.ExchangeContext(ctx, q.Copy(), up.addr)
|
||||
caller := ctx
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
// Copy the memoised client: per-attempt limits must never mutate shared state.
|
||||
client := *p.clientFor(up)
|
||||
client.Timeout = timeout
|
||||
resp, rtt, err := exchangeContext(ctx, &client, q.Copy(), up.addr)
|
||||
if err != nil {
|
||||
up.Failure(err, thresholdFor(up, cfg.BreakerThreshold), cfg.BreakerCooldown.D())
|
||||
// Cancellation by the caller (including losing a parallel race) says
|
||||
// nothing about upstream health. Only an attempt failure counts.
|
||||
if caller.Err() == nil {
|
||||
up.Failure(err, thresholdFor(up, cfg.BreakerThreshold), cfg.BreakerCooldown.D())
|
||||
}
|
||||
return nil, up.addr, err
|
||||
}
|
||||
up.Success(rtt)
|
||||
if !resp.Truncated {
|
||||
return resp, up.addr, nil
|
||||
}
|
||||
// Retry over TCP for the full answer. An upstream that is UDP-only just
|
||||
// fails here and we return the truncated answer; the client retries over
|
||||
// TCP itself.
|
||||
// Retry over TCP within the same attempt deadline. If TCP fails before
|
||||
// the budget expires, retain the truncated answer for the client to retry.
|
||||
tcpClient := &dns.Client{Net: "tcp", Timeout: timeout}
|
||||
resp2, rtt2, err2 := tcpClient.ExchangeContext(ctx, q.Copy(), up.addr)
|
||||
resp2, rtt2, err2 := exchangeContext(ctx, tcpClient, q.Copy(), up.addr)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, up.addr, err
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); ok && !time.Now().Before(deadline) {
|
||||
return nil, up.addr, context.DeadlineExceeded
|
||||
}
|
||||
if err2 != nil || resp2 == nil || resp2.Truncated {
|
||||
if err2 == nil {
|
||||
err2 = errors.New("still truncated over TCP")
|
||||
@@ -654,6 +671,26 @@ func (p *Pool) exchangeOne(ctx context.Context, cfg config.Upstream, up *Upstrea
|
||||
return resp2, up.addr, nil
|
||||
}
|
||||
|
||||
// exchangeContext also interrupts active socket I/O on caller cancellation;
|
||||
// miekg/dns applies context deadlines but only checks cancellation while dialing.
|
||||
func exchangeContext(ctx context.Context, client *dns.Client, q *dns.Msg, addr string) (*dns.Msg, time.Duration, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
conn, err := client.DialContext(ctx, addr)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer conn.Close()
|
||||
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
|
||||
defer stop()
|
||||
resp, rtt, err := client.ExchangeWithConnContext(ctx, q, conn)
|
||||
if ctx.Err() != nil {
|
||||
return nil, rtt, ctx.Err()
|
||||
}
|
||||
return resp, rtt, err
|
||||
}
|
||||
|
||||
// HealthCheck probes every upstream with a cheap query, refreshing health
|
||||
// state even when no client traffic is flowing. It returns the number of
|
||||
// upstreams that answered.
|
||||
|
||||
Reference in new issue
Block a user