Stop asinfo-gen when the as.csv download breaks off (closes #177) #178

Merged
clawbot merged 1 commits from issue-177-asinfo-gen-read-error into next 2026-10-27 03:10:41 +01:00
3 changed files with 81 additions and 2 deletions
+3
View File
@@ -33,6 +33,9 @@ tested.
# Completed Steps # Completed Steps
- 2026-10-27: `make asupdate` stops with an error when the download of `as.csv`
breaks off. The generator used to retry the failed read forever. A record that
does not parse is still logged and skipped (closes #177)
- 2026-10-27: `make asupdate` runs the new `script/asupdate`. It replaces the AS - 2026-10-27: `make asupdate` runs the new `script/asupdate`. It replaces the AS
list built into RouteWatch only when the generator succeeds and finds at least list built into RouteWatch only when the generator succeeds and finds at least
one AS. A failed download used to replace the list with an empty file. The one AS. A failed download used to replace the list with an empty file. The
+9 -2
View File
@@ -85,7 +85,8 @@ func main() {
// parseCSV reads as.csv from r and returns its ASes, with the handle and the // parseCSV reads as.csv from r and returns its ASes, with the handle and the
// description as the file spells them. A header whose first columns are not // description as the file spells them. A header whose first columns are not
// the AS number, the handle and the description is an error. A record that // the AS number, the handle and the description is an error. A record that
// does not parse is logged and skipped. A file left with no AS is an error. // does not parse is logged and skipped. A read that fails, such as on a broken
// download, is an error. A file left with no AS is an error.
func parseCSV(r io.Reader) ([]ASInfo, error) { func parseCSV(r io.Reader) ([]ASInfo, error) {
reader := csv.NewReader(r) reader := csv.NewReader(r)
@@ -109,12 +110,18 @@ func parseCSV(r io.Reader) ([]ASInfo, error) {
break break
} }
if err != nil { if _, ok := errors.AsType[*csv.ParseError](err); ok {
log.Printf("Error reading CSV record: %v", err) log.Printf("Error reading CSV record: %v", err)
continue continue
} }
// Any other error comes from the download, such as a broken
// connection. Every later read returns it again.
if err != nil {
return nil, fmt.Errorf("read CSV record: %w", err)
}
asn, err := strconv.Atoi(record[0]) asn, err := strconv.Atoi(record[0])
if err != nil { if err != nil {
log.Printf("Invalid ASN %q: %v", record[0], err) log.Printf("Invalid ASN %q: %v", record[0], err)
+69
View File
@@ -2,11 +2,18 @@ package main
import ( import (
"errors" "errors"
"io"
"slices" "slices"
"strings" "strings"
"testing" "testing"
"testing/iotest"
"time"
) )
// parseTimeLimit is how long a test waits for parseCSV to return before it
// fails.
const parseTimeLimit = 2 * time.Second
// TestParseCSVReadsTodaysLayout parses an as.csv with the country code column // TestParseCSVReadsTodaysLayout parses an as.csv with the country code column
// that the source added after the description. It expects each AS with its // that the source added after the description. It expects each AS with its
// handle and description spelled exactly as in the file, accents included. // handle and description spelled exactly as in the file, accents included.
@@ -71,3 +78,65 @@ func TestParseCSVRejectsAFileWithNoASes(t *testing.T) {
} }
} }
} }
// TestParseCSVSkipsARecordThatDoesNotParse parses an as.csv with a record
// that has too few fields between two valid records. It expects both valid
// ASes and no error, so that one bad record does not stop make asupdate.
func TestParseCSVSkipsARecordThatDoesNotParse(t *testing.T) {
t.Parallel()
const asCSV = "asn,handle,description,country-code\n" +
"1,LVLT-1,Level 3 Parent LLC,US\n" +
"2,UDEL-DCN\n" +
"3,MIT-GATEWAYS,Massachusetts Institute of Technology,US\n"
got, err := parseCSV(strings.NewReader(asCSV))
if err != nil {
t.Fatalf("parseCSV: %v", err)
}
want := []ASInfo{
{ASN: 1, Handle: "LVLT-1", Description: "Level 3 Parent LLC"},
{
ASN: 3,
Handle: "MIT-GATEWAYS",
Description: "Massachusetts Institute of Technology",
},
}
if !slices.Equal(got, want) {
t.Errorf("parseCSV = %+v, want %+v", got, want)
}
}
// TestParseCSVStopsWhenTheDownloadBreaksOff parses an as.csv whose download
// breaks off after the first record. From then on every read fails, as on a
// broken connection. It expects parseCSV to return that error, so that make
// asupdate keeps the old list.
func TestParseCSVStopsWhenTheDownloadBreaksOff(t *testing.T) {
t.Parallel()
const asCSV = "asn,handle,description,country-code\n" +
"1,LVLT-1,Level 3 Parent LLC,US\n"
download := io.MultiReader(
strings.NewReader(asCSV), iotest.ErrReader(io.ErrUnexpectedEOF))
// parseCSV runs on its own goroutine, so that a parseCSV that never
// returns fails the test instead of hanging it.
parsed := make(chan error, 1)
go func() {
_, err := parseCSV(download)
parsed <- err
}()
select {
case err := <-parsed:
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Errorf("parseCSV = %v, want %v", err, io.ErrUnexpectedEOF)
}
case <-time.After(parseTimeLimit):
t.Fatalf("parseCSV did not return within %v", parseTimeLimit)
}
}