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), and logs it with
the reason unless shutdown cut it short. A nameserver that answered no
type has failed, as before.
The watcher saves such a type in failedTypes, keeping the previous
check's records, leaves it out of the comparison with other nameservers
on that check, and compares the kept records with the next answer. With
nothing to keep, it is also in unknownTypes and not compared until it
answers. Change messages leave out what was not compared. A nameserver
whose A, AAAA or CNAME query failed is no answer when following a CNAME
or resolving addresses.

Model: opus-5-5
This commit is contained in:
2026-10-02 07:06:42 +00:00
parent b047c3c64c
commit d735de8746
15 changed files with 752 additions and 54 deletions
+69 -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,13 @@ 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, logging each with the reason unless ctx was
// cancelled: shutdown cancels it, and a query it cut short did not fail.
func (r *Resolver) queryEachType(
ctx context.Context,
nsIP string,
@@ -654,7 +662,34 @@ func (r *Resolver) queryEachType(
break
}
r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
err := r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
if err == nil {
state.answered = true
continue
}
rtype := dns.TypeToString[qtype]
resp.FailedTypes = append(resp.FailedTypes, rtype)
if errors.Is(ctx.Err(), context.Canceled) {
continue
}
r.log.Warn(
"record type query failed",
"hostname", hostname,
"nameserver", resp.Nameserver,
"type", rtype,
"error", err,
)
}
// 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 +699,9 @@ func (r *Resolver) queryEachType(
return state
}
// querySingleType asks the nameserver at nsIP about hostname's records
// of type qtype. It returns nil when the nameserver answered: with
// records, with none, or with NXDOMAIN; otherwise it returns why not.
func (r *Resolver) querySingleType(
ctx context.Context,
nsIP string,
@@ -671,7 +709,7 @@ func (r *Resolver) querySingleType(
qtype uint16,
resp *NameserverResponse,
state *queryState,
) {
) error {
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
if err != nil {
switch {
@@ -683,19 +721,19 @@ func (r *Resolver) querySingleType(
state.netErr = err
}
return
return err
}
if msg.Rcode == dns.RcodeNameError {
state.gotNXDomain = true
return
return nil
}
if msg.Rcode == dns.RcodeServerFailure {
state.gotSERVFAIL = true
return
return fmt.Errorf("server returned SERVFAIL: %w", ErrUnusableReply)
}
// A reply with no answer that lists other nameservers, from a server
@@ -708,10 +746,20 @@ func (r *Resolver) querySingleType(
len(extractNSSet(msg.Ns)) > 0 {
state.gotReferral = true
return
return fmt.Errorf("server returned a referral: %w", ErrUnusableReply)
}
// A reply still truncated is one whose TCP retry failed, and holds
// only the records that fit.
if msg.Truncated {
state.netErr = ErrTruncated
return ErrTruncated
}
collectAnswerRecords(msg, resp, state)
return nil
}
func collectAnswerRecords(
@@ -743,23 +791,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 +971,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 +988,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
}