Lengthen the WHOIS fetcher's wait after a skipped lookup (closes #222)
check / check (push) Canceled after 0s

A lookup whose RIR is paused is skipped, and the skip left the fetcher's
wait as it was. Each lookup asks IANA before it finds the RIR paused. So
while most stale ASes were at a paused RIR, the fetcher could ask IANA
every second.

A skipped lookup now makes the wait 1.5 times as long, up to one minute,
as a failure does. It is still not counted as a failure, so the status
page's failure figures leave it out.

Model: opus-5-5
This commit is contained in:
2026-11-02 02:03:46 +00:00
parent df15e2b2cd
commit 100abc196e
3 changed files with 65 additions and 8 deletions
+5
View File
@@ -35,6 +35,11 @@ tested.
# Completed Steps # Completed Steps
- 2026-11-02: a WHOIS lookup skipped because its RIR is paused makes the fetcher
wait longer before the next lookup, as a failed lookup does, up to one minute.
The status page does not count a skip as a failure. A skip used to leave the
wait as it was. Each lookup asks IANA first, so while most stale ASes were at
a paused RIR, the fetcher could ask IANA every second (closes #222)
- 2026-11-02: RouteWatch pauses an RIR's or NIC.br's WHOIS server for an hour - 2026-11-02: RouteWatch pauses an RIR's or NIC.br's WHOIS server for an hour
after the server refuses a query. A refused or reset connection is a refusal. after the server refuses a query. A refused or reset connection is a refusal.
So is an answer with an `%ERROR:201:` or `%ERROR:202:` line. Lookups at the So is an answer with an `%ERROR:201:` or `%ERROR:202:` line. Lookups at the
+19 -8
View File
@@ -187,25 +187,33 @@ func (f *ASNFetcher) recordSuccess() {
f.statsMu.Unlock() f.statsMu.Unlock()
} }
// recordFailure increases the interval on failed fetch using exponential backoff. // lengthenInterval makes the interval 1.5 times as long, up to maxInterval.
func (f *ASNFetcher) recordFailure() { // A failed lookup and a skipped one both lengthen it.
func (f *ASNFetcher) lengthenInterval() {
f.intervalMu.Lock() f.intervalMu.Lock()
f.consecutiveFails++ defer f.intervalMu.Unlock()
// Exponential backoff: multiply by 1.5, capped at max
newInterval := min( newInterval := min(
f.currentInterval*backoffNumerator/backoffDenominator, maxInterval) f.currentInterval*backoffNumerator/backoffDenominator, maxInterval)
if newInterval != f.currentInterval { if newInterval != f.currentInterval {
f.logger.Debug("Increased fetch interval due to failure", f.logger.Debug("Increased fetch interval",
"old_interval", f.currentInterval, "old_interval", f.currentInterval,
"new_interval", newInterval, "new_interval", newInterval,
"consecutive_failures", f.consecutiveFails, "consecutive_failures", f.consecutiveFails,
) )
f.currentInterval = newInterval f.currentInterval = newInterval
} }
}
// recordFailure counts a failed fetch and lengthens the interval.
func (f *ASNFetcher) recordFailure() {
f.intervalMu.Lock()
f.consecutiveFails++
f.intervalMu.Unlock() f.intervalMu.Unlock()
f.lengthenInterval()
// Record error time for stats, dropping those older than statsWindow // Record error time for stats, dropping those older than statsWindow
f.statsMu.Lock() f.statsMu.Lock()
now := f.now() now := f.now()
@@ -291,9 +299,11 @@ func (f *ASNFetcher) fetchNextStale(ctx context.Context) {
// The WHOIS client pauses an RIR's or NIC.br's server for whois.PauseTime // The WHOIS client pauses an RIR's or NIC.br's server for whois.PauseTime
// after the server refuses a query. Lookups at the other servers go on. IANA // after the server refuses a query. Lookups at the other servers go on. IANA
// is never paused, so a refusal by IANA is an ordinary failure. A lookup // is never paused, so a refusal by IANA is an ordinary failure. A lookup
// whose RIR is paused fails with whois.ErrPaused. Such a lookup leaves the // whose RIR is paused fails with whois.ErrPaused, and is skipped. A skipped
// AS stale, and counts as neither a success nor a failure. The lookups that // lookup leaves the AS stale and is not counted as a failure. It lengthens
// QueueImmediate asks for also run through fetchAndUpdate. // the interval as a failure does: each lookup asks IANA before it finds the
// RIR paused, and a short interval would ask IANA every second. The lookups
// that QueueImmediate asks for also run through fetchAndUpdate.
func (f *ASNFetcher) fetchAndUpdate(ctx context.Context, asn int) bool { func (f *ASNFetcher) fetchAndUpdate(ctx context.Context, asn int) bool {
ctx, cancel := context.WithTimeout(ctx, time.Minute) ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel() defer cancel()
@@ -317,6 +327,7 @@ func (f *ASNFetcher) fetchAndUpdate(ctx context.Context, asn int) bool {
if errors.Is(err, whois.ErrPaused) { if errors.Is(err, whois.ErrPaused) {
f.logger.Info("Skipped WHOIS lookup, the RIR's server is paused", f.logger.Info("Skipped WHOIS lookup, the RIR's server is paused",
"asn", asn, "error", err) "asn", asn, "error", err)
f.lengthenInterval()
return false return false
} }
@@ -196,6 +196,47 @@ func TestFetchAndUpdateWaitsOutAPause(t *testing.T) {
} }
} }
// TestFetchAndUpdateBacksOffDuringAPause looks up an AS at RIPE while RIPE
// refuses the query. The test sets the WHOIS client's clock. It looks the AS
// up again each time the interval has passed, so each later lookup is
// skipped. Each skip must make the interval 1.5 times as long, up to one
// minute, as a failure does. The skips must not count as failures.
func TestFetchAndUpdateBacksOffDuringAPause(t *testing.T) {
t.Parallel()
f, _ := newFetcherWithWHOISAnswers(t, map[string]string{
ianaWHOISServer: ianaRefersToRIPE,
ripeWHOISServer: ripeAccessDenied,
})
now := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
f.whoisClient.Now = func() time.Time { return now }
f.fetchAndUpdate(t.Context(), testASNA)
for i, want := range []time.Duration{
33750 * time.Millisecond,
50625 * time.Millisecond,
time.Minute,
time.Minute,
} {
now = now.Add(f.getInterval())
f.fetchAndUpdate(t.Context(), testASNA)
if got := f.getInterval(); got != want {
t.Errorf("interval after %d skipped lookups = %v, want %v",
i+1, got, want)
}
}
got := f.GetStats()
if got.ConsecutiveFails != 1 || got.ErrorsLastHour != 1 {
t.Errorf("%d failures in a row and %d errors, want 1 and 1",
got.ConsecutiveFails, got.ErrorsLastHour)
}
}
// TestFetchAndUpdateKeepsAskingIANA looks up an AS while IANA's server // TestFetchAndUpdateKeepsAskingIANA looks up an AS while IANA's server
// refuses the connection. The test sets the WHOIS client's clock. The next // refuses the connection. The test sets the WHOIS client's clock. The next
// lookup comes a second before whois.PauseTime has passed. IANA is never // lookup comes a second before whois.PauseTime has passed. IANA is never