1 Commits
Author SHA1 Message Date
sneak b0b02811ce Pause a registry's WHOIS server after it refuses a query (closes #218)
check / check (push) Canceled after 0s
The fetcher kept one interval for every registry, so a registry that
refused a query was asked again within seconds. RIPE blocked the test
host's address that way.

The WHOIS client now pauses an RIR's or NIC.br's server for an hour
after the server refuses a query. A refused or reset connection is a
refusal, and so is an %ERROR:201: or %ERROR:202: line. Lookups at the
other servers go on. A lookup whose RIR is paused leaves the AS stale,
and counts as neither a success nor a failure. IANA is never paused,
because a pause of IANA would stop every lookup. NIC.br's hour-long
skip after its over-limit answer is now the same pause.

Model: opus-5-5
2026-11-02 01:06:26 +00:00
5 changed files with 105 additions and 55 deletions
+8 -8
View File
@@ -35,14 +35,14 @@ tested.
# Completed Steps
- 2026-11-02: after a WHOIS server refuses a query, RouteWatch asks it nothing
for an hour. A refusal is a refused or reset connection, or an `%ERROR:201:`
or `%ERROR:202:` line, with which RIPE, APNIC and AFRINIC refuse a query.
RouteWatch still asks the other servers. An AS whose server is paused is
looked up after the pause. Every lookup asks IANA first, so a pause of IANA
stops them all. The fetcher used to ask a server that had refused it again
within seconds. NIC.br's answer that RouteWatch is over its limit starts the
same pause (closes #218)
- 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.
So is an answer with an `%ERROR:201:` or `%ERROR:202:` line. Lookups at the
other servers go on. An AS whose RIR is paused is looked up after the pause.
IANA is never paused, because a pause of IANA would stop every lookup. A
refusal by IANA is an ordinary failure. NIC.br is also paused for an hour
after it says RouteWatch has passed NIC.br's query limit. Before, the fetcher
asked a server again within seconds of a refusal (closes #218)
- 2026-10-27: the tests of `internal/routewatch` no longer fail on a loaded
host. Five of them gave `HandleMessage` two seconds to apply a full prefix
batch, which under the race detector can take longer. A test now waits up to
+7 -6
View File
@@ -288,11 +288,12 @@ func (f *ASNFetcher) fetchNextStale(ctx context.Context) {
// WHOIS data, so it is not looked up again until it is stale; that counts
// as neither a success nor a failure.
//
// A WHOIS server that refuses a query is paused for whois.PauseTime, and
// the other servers are still asked. A lookup that needs a paused server
// sends it nothing. It also leaves the AS stale, and counts as neither a
// success nor a failure. This holds for the lookups that QueueImmediate
// asks for too.
// 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
// 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
// AS stale, and counts as neither a success nor a failure. The lookups that
// QueueImmediate asks for also run through fetchAndUpdate.
func (f *ASNFetcher) fetchAndUpdate(ctx context.Context, asn int) bool {
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
@@ -314,7 +315,7 @@ func (f *ASNFetcher) fetchAndUpdate(ctx context.Context, asn int) bool {
}
if errors.Is(err, whois.ErrPaused) {
f.logger.Info("Skipped WHOIS lookup, a server it needs is paused",
f.logger.Info("Skipped WHOIS lookup, the RIR's server is paused",
"asn", asn, "error", err)
return false
@@ -133,10 +133,10 @@ func TestFetchAndUpdateKeepsTheRowWhenTheLookupFails(t *testing.T) {
}
// TestFetchAndUpdateWaitsOutAPause looks up an AS at RIPE while RIPE refuses
// the query, and again a second before whois.PauseTime has passed, by a clock
// the test sets on the WHOIS client. The second lookup sends RIPE nothing,
// leaves the AS never looked up, and counts as neither a success nor a
// failure. A lookup at whois.PauseTime asks RIPE again.
// the query. The test sets the WHOIS client's clock. The next lookup comes a
// second before whois.PauseTime has passed. It sends RIPE nothing and leaves
// the AS never looked up. It counts as neither a success nor a failure. A
// lookup at whois.PauseTime asks RIPE again.
func TestFetchAndUpdateWaitsOutAPause(t *testing.T) {
t.Parallel()
@@ -196,6 +196,45 @@ func TestFetchAndUpdateWaitsOutAPause(t *testing.T) {
}
}
// TestFetchAndUpdateKeepsAskingIANA looks up an AS while IANA's server
// 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
// paused, so that lookup asks IANA again. Each refusal counts as a failure.
func TestFetchAndUpdateKeepsAskingIANA(t *testing.T) {
t.Parallel()
// With no answers, every server refuses the connection.
f, _ := newFetcherWithWHOISAnswers(t, map[string]string{})
ianaAsks := 0
dial := f.whoisClient.DialContext
f.whoisClient.DialContext = func(
ctx context.Context, network, address string,
) (net.Conn, error) {
if address == ianaWHOISServer {
ianaAsks++
}
return dial(ctx, network, address)
}
start := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
now := start
f.whoisClient.Now = func() time.Time { return now }
f.fetchAndUpdate(t.Context(), testASNA)
now = start.Add(whois.PauseTime - time.Second)
f.fetchAndUpdate(t.Context(), testASNA)
got := f.GetStats()
if ianaAsks != 2 || got.ConsecutiveFails != 2 {
t.Errorf("IANA was asked %d times, with %d failures in a row; "+
"want 2 and 2", ianaAsks, got.ConsecutiveFails)
}
}
// TestRecordStepsTheInterval has the fetcher fail from its base interval of
// 15 seconds, then succeed. Each failure makes the interval 1.5 times as
// long, up to one minute, and each success halves it, down to one second.
+33 -24
View File
@@ -71,10 +71,11 @@ const nicbrHeader = "% Copyright (c) Nic.br"
// queries to stop the data being taken in bulk.
const nicbrLimitReached = "Query rate limit exceeded"
// PauseTime is how long the client asks a WHOIS server nothing after the
// server refuses a query, as ask says. RIPE refuses an address for good
// that keeps passing its limit, as noPersonalFlag says. An hour outlasts a
// limit counted per minute or per hour.
// PauseTime is how long the client sends an RIR's or NIC.br's server no
// query after that server refuses a query. queryRegistry says what a
// refusal is. RIPE refuses an address for good when the address keeps
// passing RIPE's limit (see noPersonalFlag). An hour outlasts a limit
// counted per minute or per hour.
const PauseTime = time.Hour
// RIR identifiers.
@@ -163,8 +164,8 @@ type Client struct {
// mu guards pausedUntil.
mu sync.Mutex
// pausedUntil holds, for each server that refused a query, given as
// host:port, when the client next asks it.
// pausedUntil maps a server, given as host:port, to the time the client
// may ask it again.
pausedUntil map[string]time.Time
}
@@ -186,16 +187,20 @@ func NewClient(logger *slog.Logger) *Client {
// NIC.br is paused, as askNICbr says.
//
// It returns ErrNoReferral when IANA refers the AS to no RIR. Its error
// wraps ErrPaused when IANA or the RIR is paused. It returns another error
// when the query to IANA or to the RIR fails, when the RIR refuses it, when
// IANA's answer has neither a referral nor the AS's block, when IANA refers
// the AS to a server none of the five RIRs run, or when the answer carries
// none of ASNInfo's fields.
// wraps ErrPaused when the RIR is paused. It returns another error in each
// of these cases:
//
// - the query to IANA or to the RIR fails
// - the RIR refuses the query
// - IANA's answer has neither a referral nor the AS's block
// - IANA refers the AS to a server none of the five RIRs run
// - the answer carries none of ASNInfo's fields
func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
// Query IANA first to find the authoritative RIR
query := fmt.Sprintf("AS%d", asn)
ianaResp, err := c.ask(ctx, whoisServerIANA, query)
// IANA is never paused, because a pause of IANA would stop every lookup.
ianaResp, err := c.query(ctx, whoisServerIANA, query)
if err != nil {
return nil, fmt.Errorf("IANA query failed: %w", err)
}
@@ -217,7 +222,7 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
}
// Query the authoritative RIR
rirResp, err := c.ask(ctx, whoisServer, rirQuery(rir, query))
rirResp, err := c.queryRegistry(ctx, whoisServer, rirQuery(rir, query))
if err != nil {
return nil, fmt.Errorf("RIR query failed: %w", err)
}
@@ -255,7 +260,7 @@ func (c *Client) askNICbr(
) *ASNInfo {
asn := lacnicInfo.ASN
answer, err := c.ask(ctx, whoisServerNICbr, asQuery)
answer, err := c.queryRegistry(ctx, whoisServerNICbr, asQuery)
if errors.Is(err, ErrPaused) {
return lacnicInfo
}
@@ -310,16 +315,19 @@ func rirQuery(rir, asQuery string) string {
}
}
// ask sends query to server and returns the answer. A server that refuses
// the query is paused: ask then sends it nothing until PauseTime has
// passed, and returns an error that wraps ErrPaused instead. Each of these
// is a refusal, and an error:
// queryRegistry sends query to the server of an RIR or of NIC.br, and
// returns the answer. The client pauses the server for PauseTime when the
// server refuses the query. Each of these is a refusal:
//
// - a refused connection
// - a reset connection
// - an answer with one of the error lines that RIPE's database software
// refuses a query with. The error holds that line.
func (c *Client) ask(ctx context.Context, server, query string) (string, error) {
// - an %ERROR:201: or %ERROR:202: line in the answer
//
// A refusal is returned as an error. While the server is paused,
// queryRegistry sends it nothing and returns an error that wraps ErrPaused.
func (c *Client) queryRegistry(
ctx context.Context, server, query string,
) (string, error) {
c.mu.Lock()
pausedUntil := c.pausedUntil[server]
c.mu.Unlock()
@@ -346,8 +354,9 @@ func (c *Client) ask(ctx context.Context, server, query string) (string, error)
return answer, nil
}
// checkRefusal returns an error holding the first line of answer with which
// RIPE's database software refuses a query, or nil when there is none.
// checkRefusal returns an error holding the first line of answer that
// starts with accessDeniedError or accessLimitError. It returns nil when
// answer has no such line.
func checkRefusal(answer string) error {
for line := range strings.SplitSeq(answer, "\n") {
if strings.HasPrefix(line, accessDeniedError) ||
@@ -359,7 +368,7 @@ func checkRefusal(answer string) error {
return nil
}
// pause makes the client ask server nothing until PauseTime has passed.
// pause stops queryRegistry from querying server until PauseTime has passed.
func (c *Client) pause(server string) {
c.mu.Lock()
defer c.mu.Unlock()
+14 -13
View File
@@ -718,9 +718,10 @@ func TestLookupASNKeepsLACNICsAnswerWhenNICbrFails(t *testing.T) {
// TestLookupASNSkipsNICbrAfterItsLimit looks up an AS that NIC.br
// registers three times with one client, while NIC.br answers that the
// client is over its query limit. The first lookup asks NIC.br. The
// second, a second before PauseTime has passed, does not. The third, once
// PauseTime has passed, asks again. Each keeps LACNIC's fields.
// client is over its query limit. The test sets the client's clock. The
// first lookup asks NIC.br. The next comes a second before PauseTime has
// passed, and does not ask NIC.br. The last comes at PauseTime and asks
// NIC.br again. Each keeps LACNIC's fields.
func TestLookupASNSkipsNICbrAfterItsLimit(t *testing.T) {
t.Parallel()
@@ -785,9 +786,9 @@ func TestLookupASNReportsARefusal(t *testing.T) {
}
}
// resetServer starts a TCP server on the loopback address that reads one
// query on each connection and then resets the connection. It returns the
// server's address.
// resetServer starts a TCP server on the loopback address and returns the
// server's address. The server reads one query on each connection, then
// resets the connection.
func resetServer(t *testing.T) string {
t.Helper()
@@ -821,9 +822,9 @@ func resetServer(t *testing.T) string {
return listener.Addr().String()
}
// newRIPEClient returns a client whose fake servers send answers. It adds
// one to *ripeAsks each time it connects to RIPE's server. When reset is
// true, RIPE's server resets the connection instead of answering.
// newRIPEClient returns a client whose fake servers send answers. The
// client adds one to *ripeAsks each time it connects to RIPE's server. When
// reset is true, RIPE's server resets the connection instead of answering.
func newRIPEClient(
t *testing.T, answers map[string]string, reset bool, ripeAsks *int,
) *whois.Client {
@@ -853,10 +854,10 @@ func newRIPEClient(
}
// TestLookupASNPausesAServerThatRefuses looks up an AS at RIPE while RIPE
// refuses the query, in each of the ways a server refuses. A lookup of the
// AS a second before PauseTime has passed, by a clock the test sets, fails
// with ErrPaused and asks RIPE nothing. A lookup of an AS at APNIC still
// succeeds then. A lookup of the AS at PauseTime asks RIPE again.
// refuses the query, in each of the ways a server refuses. The test sets
// the client's clock. A lookup a second before PauseTime has passed fails
// with ErrPaused and asks RIPE nothing. A lookup of an AS at APNIC succeeds
// during the pause. A lookup at PauseTime asks RIPE again.
func TestLookupASNPausesAServerThatRefuses(t *testing.T) {
t.Parallel()