resolver, watcher: a record type whose query fails keeps its previous records (closes #231)
check / check (push) Canceled after 0s

The resolver lists in FailedTypes each record type whose query to a
nameserver got no usable reply: none after two tries, an error reply, a
referral, or a truncated reply whose TCP retry failed. Records holds
nothing for such a type, never none or the part that fit. A nameserver
that answered no type has failed, as before. The watcher keeps the
previous check's records for a failed type; when that check did not know
them either (first check, new or failing nameserver), the type is saved
in failedTypes and left out of record and inconsistency comparisons.
ResolveIPAddresses no longer counts a nameserver whose A, AAAA or CNAME
query failed as an answer.

Model: opus-5-5
This commit is contained in:
2026-10-02 06:00:30 +00:00
parent a18803ff28
commit 0ed7667ef5
13 changed files with 418 additions and 37 deletions
+54 -15
View File
@@ -89,6 +89,9 @@ func (r *Resolver) tryExchange(
return resp, err
}
// retryTCP returns the reply to msg over TCP when resp, its reply over
// UDP, is truncated. When that fails it returns resp, still truncated,
// which holds only the records that fit.
func (r *Resolver) retryTCP(
ctx context.Context,
msg *dns.Msg,
@@ -638,8 +641,12 @@ type queryState struct {
gotReferral bool
netErr error
hasRecords bool
answered bool
}
// queryEachType asks the nameserver at nsIP about hostname once for each
// record type in qtypes, and lists in resp.FailedTypes the types whose
// query got no usable reply.
func (r *Resolver) queryEachType(
ctx context.Context,
nsIP string,
@@ -654,7 +661,20 @@ func (r *Resolver) queryEachType(
break
}
r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
if r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state) {
state.answered = true
} else {
resp.FailedTypes = append(
resp.FailedTypes, dns.TypeToString[qtype],
)
}
}
// The reply about another type can carry the name's CNAME. When the
// query for CNAME itself failed, that is left out too, so Records
// holds nothing for a failed type.
for _, rtype := range resp.FailedTypes {
delete(resp.Records, rtype)
}
for k := range resp.Records {
@@ -664,6 +684,9 @@ func (r *Resolver) queryEachType(
return state
}
// querySingleType asks the nameserver at nsIP about hostname's records
// of type qtype, and reports whether it answered: with records, with
// none, or with NXDOMAIN.
func (r *Resolver) querySingleType(
ctx context.Context,
nsIP string,
@@ -671,7 +694,7 @@ func (r *Resolver) querySingleType(
qtype uint16,
resp *NameserverResponse,
state *queryState,
) {
) bool {
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
if err != nil {
switch {
@@ -683,19 +706,19 @@ func (r *Resolver) querySingleType(
state.netErr = err
}
return
return false
}
if msg.Rcode == dns.RcodeNameError {
state.gotNXDomain = true
return
return true
}
if msg.Rcode == dns.RcodeServerFailure {
state.gotSERVFAIL = true
return
return false
}
// A reply with no answer that lists other nameservers, from a server
@@ -708,10 +731,20 @@ func (r *Resolver) querySingleType(
len(extractNSSet(msg.Ns)) > 0 {
state.gotReferral = true
return
return false
}
// A reply still truncated is one whose TCP retry failed, and holds
// only the records that fit.
if msg.Truncated {
state.netErr = ErrTruncated
return false
}
collectAnswerRecords(msg, resp, state)
return true
}
func collectAnswerRecords(
@@ -743,23 +776,26 @@ func isTimeout(err error) bool {
return false
}
// classifyResponse sets the nameserver's status. One that answered no
// record type has failed, and Error says why; one that answered some has
// the status of those answers.
func classifyResponse(resp *NameserverResponse, state queryState) {
switch {
case state.gotNXDomain && !state.hasRecords:
resp.Status = StatusNXDomain
case state.gotTimeout && !state.hasRecords:
case state.gotTimeout && !state.answered:
resp.Status = StatusTimeout
resp.Error = "all queries timed out"
case state.gotSERVFAIL && !state.hasRecords:
case state.gotSERVFAIL && !state.answered:
resp.Status = StatusError
resp.Error = "server returned SERVFAIL"
case state.gotRefused && !state.hasRecords:
case state.gotRefused && !state.answered:
resp.Status = StatusError
resp.Error = "server returned REFUSED"
case state.netErr != nil && !state.hasRecords:
case state.netErr != nil && !state.answered:
resp.Status = StatusError
resp.Error = "network error: " + state.netErr.Error()
case state.gotReferral && !state.hasRecords:
case state.gotReferral && !state.answered:
resp.Status = StatusError
resp.Error = "server returned a referral"
case !state.hasRecords && !state.gotNXDomain:
@@ -920,9 +956,11 @@ func (r *Resolver) resolveIPWithCNAME(
}
// collectIPs returns the addresses in the nameservers' answers and the
// first CNAME target among them. It returns ErrNoNameserverAnswered when
// every nameserver timed out, failed or returned a referral: that is not
// a name with no addresses.
// first CNAME target among them. A nameserver whose query for one of the
// types failed gave only part of the addresses, and is left out. It
// returns ErrNoNameserverAnswered when every nameserver timed out,
// failed, returned a referral or was left out: that is not a name with
// no addresses.
func collectIPs(
results map[string]*NameserverResponse,
) ([]string, string, error) {
@@ -935,7 +973,8 @@ func collectIPs(
answered := false
for _, resp := range results {
if resp.Status == StatusTimeout || resp.Status == StatusError {
if resp.Status == StatusTimeout || resp.Status == StatusError ||
len(resp.FailedTypes) > 0 {
continue
}