Re-vendor the shared files from sneak/prompts at 0653579 (closes #52)
check / check (push) Canceled after 0s

Takes .golangci.yml, .dockerignore, .gitignore, .editorconfig, the
workflow and REPO_POLICIES.md from sneak/prompts 0653579, with this
repository's own entries after the shared content. Lint (golangci-lint
v2.14.0) and test are Dockerfile phases that script/lint and script/test
build alone with a new CACHEBUST value, keeping Go's and golangci-lint's
caches; the image build depends on both. make fmt and make fmt-check also
format the markdown with prettier, pinned in yarn.lock. The agent
guidance moves to AGENTS.md. The Go code is fixed for what the new
configuration finds, and the AS and prefix detail reads now return the
rows' error after their loops.

Model: opus-5-5
This commit is contained in:
2026-10-19 10:00:20 +00:00
committed by sneak
parent 4f515d718b
commit 0b71832569
65 changed files with 5624 additions and 3509 deletions
File diff suppressed because it is too large Load Diff
@@ -22,12 +22,26 @@ import (
"github.com/google/uuid"
)
// The prefix and peer addresses the prefix page tests store routes for.
const (
testPrefixV4 = "192.0.2.0/24"
testPeerV4 = "198.51.100.1"
testPeerV6 = "2001:db8::1"
)
// The names the stats handler tests report the two stats handlers by.
const (
statusJSONHandlerName = "status.json"
statsHandlerName = "stats"
)
// blockingStatsDB embeds database.Store (left nil) and overrides only
// GetStatsContext, which blocks until release is closed. The stats handlers
// call it in a goroutine; every other Store method is unused on the timeout
// path and would panic if called.
type blockingStatsDB struct {
database.Store
release chan struct{}
}
@@ -43,14 +57,16 @@ func (d blockingStatsDB) GetStatsContext(_ context.Context) (database.Stats, err
// value. Before the fix the per-request goroutine sent on an unbuffered channel
// that nothing received once the timeout won, so it blocked forever and every
// poll leaked one goroutine.
//
//nolint:paralleltest // counts goroutines, so no other test may run alongside
func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) {
release := make(chan struct{})
db := blockingStatsDB{release: release}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
handlers := map[string]http.HandlerFunc{
"status.json": s.handleStatusJSON(),
"stats": s.handleStats(),
statusJSONHandlerName: s.handleStatusJSON(),
statsHandlerName: s.handleStats(),
}
baseline := settledGoroutineCount()
@@ -62,7 +78,7 @@ func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) {
for _, handler := range handlers {
for range iterations {
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
handler(httptest.NewRecorder(), req)
cancel()
}
@@ -87,67 +103,83 @@ func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) {
// The oldest and newest route times still come from one-row lookups at the ends
// of an index; with the database closed they are left out of the answer.
func TestStatsHandlersAnswerFromMemory(t *testing.T) {
t.Parallel()
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
if err := db.UpsertLiveRouteBatch([]*database.LiveRoute{
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{
{
ID: uuid.New(), Prefix: "198.51.100.0/24", MaskLength: 24, IPVersion: 4,
OriginASN: 64500, PeerIP: "192.0.2.1", ASPath: []int{64500}, NextHop: "192.0.2.1",
LastUpdated: ts,
OriginASN: 64500, PeerIP: "192.0.2.1", ASPath: []int{64500},
NextHop: "192.0.2.1", LastUpdated: ts,
},
{
ID: uuid.New(), Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6,
OriginASN: 64501, PeerIP: "2001:db8::1", ASPath: []int{64501}, NextHop: "2001:db8::1",
LastUpdated: ts,
OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501},
NextHop: testPeerV6, LastUpdated: ts,
},
}); err != nil {
})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
if err := db.Close(); err != nil {
err = db.Close()
if err != nil {
t.Fatalf("Close: %v", err)
}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
handlers := map[string]http.HandlerFunc{
"status.json": s.handleStatusJSON(),
"stats": s.handleStats(),
statusJSONHandlerName: s.handleStatusJSON(),
statsHandlerName: s.handleStats(),
}
for name, handler := range handlers {
rec := httptest.NewRecorder()
handler(rec, httptest.NewRequest(http.MethodGet, "/", nil))
handler(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil))
if rec.Code != http.StatusOK {
t.Errorf("%s: status %d, want %d; body %s", name, rec.Code, http.StatusOK, rec.Body)
t.Errorf("%s: status %d, want %d; body %s",
name, rec.Code, http.StatusOK, rec.Body)
continue
}
//nolint:tagliatelle // field names are the existing JSON API
var body struct {
Data struct {
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
IPv4Distribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6Distribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Fatalf("%s: decoding the answer: %v", name, err)
}
if body.Data.IPv4Routes != 1 || body.Data.IPv6Routes != 1 {
t.Errorf("%s: routes = (v4 %d, v6 %d), want (1, 1)", name, body.Data.IPv4Routes, body.Data.IPv6Routes)
t.Errorf("%s: routes = (v4 %d, v6 %d), want (1, 1)",
name, body.Data.IPv4Routes, body.Data.IPv6Routes)
}
wantV4 := []database.PrefixDistribution{{MaskLength: 24, Count: 1}}
if !slices.Equal(body.Data.IPv4PrefixDistribution, wantV4) {
t.Errorf("%s: IPv4 distribution = %v, want %v", name, body.Data.IPv4PrefixDistribution, wantV4)
if !slices.Equal(body.Data.IPv4Distribution, wantV4) {
t.Errorf("%s: IPv4 distribution = %v, want %v",
name, body.Data.IPv4Distribution, wantV4)
}
wantV6 := []database.PrefixDistribution{{MaskLength: 32, Count: 1}}
if !slices.Equal(body.Data.IPv6PrefixDistribution, wantV6) {
t.Errorf("%s: IPv6 distribution = %v, want %v", name, body.Data.IPv6PrefixDistribution, wantV6)
if !slices.Equal(body.Data.IPv6Distribution, wantV6) {
t.Errorf("%s: IPv6 distribution = %v, want %v",
name, body.Data.IPv6Distribution, wantV6)
}
}
}
@@ -159,25 +191,30 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
// "2001:DB8:0:0::/32" was not found although "2001:db8::/32" was stored, and
// "foo/24" answered 500 (https://git.eeqj.de/sneak/routewatch/issues/71).
func TestPrefixDetailHandlersParsePrefix(t *testing.T) {
t.Parallel()
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
if err := db.UpsertLiveRouteBatch([]*database.LiveRoute{
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{
{
ID: uuid.New(), Prefix: "192.0.2.0/24", MaskLength: 24, IPVersion: 4,
OriginASN: 64500, PeerIP: "198.51.100.1", ASPath: []int{64500}, NextHop: "198.51.100.1",
LastUpdated: ts,
ID: uuid.New(), Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
OriginASN: 64500, PeerIP: testPeerV4, ASPath: []int{64500},
NextHop: testPeerV4, LastUpdated: ts,
},
{
ID: uuid.New(), Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6,
OriginASN: 64501, PeerIP: "2001:db8::1", ASPath: []int{64501}, NextHop: "2001:db8::1",
LastUpdated: ts,
OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501},
NextHop: testPeerV6, LastUpdated: ts,
},
}); err != nil {
})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
@@ -197,20 +234,29 @@ func TestPrefixDetailHandlersParsePrefix(t *testing.T) {
for _, tt := range tests {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, tt.path, nil))
if rec.Code != tt.want {
t.Errorf("%s: status %d, want %d; body %s", tt.path, rec.Code, tt.want, rec.Body)
t.Errorf("%s: status %d, want %d; body %s",
tt.path, rec.Code, tt.want, rec.Body)
}
}
}
// errASDetailsLookupFailed is what failingASDetailsDB answers every AS details
// lookup with.
var errASDetailsLookupFailed = errors.New("AS details lookup failed")
// failingASDetailsDB wraps a real store and fails every AS details lookup.
type failingASDetailsDB struct {
database.Store
}
func (failingASDetailsDB) GetASDetailsContext(_ context.Context, _ int) (*database.ASN, []database.LiveRoute, error) {
return nil, nil, errors.New("AS details lookup failed")
func (failingASDetailsDB) GetASDetailsContext(
_ context.Context, _ int,
) (*database.ASN, []database.LiveRoute, error) {
return nil, nil, errASDetailsLookupFailed
}
// TestPrefixDetailShowsOriginNames checks that the prefix page shows each
@@ -220,51 +266,66 @@ func (failingASDetailsDB) GetASDetailsContext(_ context.Context, _ int) (*databa
// (https://git.eeqj.de/sneak/routewatch/issues/67). The AS path column shows
// handles too, so the test looks for the origin list's "(handle)" form.
func TestPrefixDetailShowsOriginNames(t *testing.T) {
t.Parallel()
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
origins := []int{15169, 13335}
for _, asn := range origins {
if _, err := db.GetOrCreateASN(asn, ts); err != nil {
_, err := db.GetOrCreateASN(asn, ts)
if err != nil {
t.Fatalf("GetOrCreateASN(%d): %v", asn, err)
}
}
if err := db.UpsertLiveRouteBatch([]*database.LiveRoute{
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{
{
ID: uuid.New(), Prefix: "192.0.2.0/24", MaskLength: 24, IPVersion: 4,
OriginASN: origins[0], PeerIP: "198.51.100.1", ASPath: []int{origins[0]}, NextHop: "198.51.100.1",
LastUpdated: ts,
ID: uuid.New(), Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
OriginASN: origins[0], PeerIP: testPeerV4,
ASPath: []int{origins[0]}, NextHop: testPeerV4, LastUpdated: ts,
},
{
ID: uuid.New(), Prefix: "192.0.2.0/24", MaskLength: 24, IPVersion: 4,
OriginASN: origins[1], PeerIP: "198.51.100.2", ASPath: []int{origins[1]}, NextHop: "198.51.100.2",
LastUpdated: ts,
ID: uuid.New(), Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
OriginASN: origins[1], PeerIP: "198.51.100.2",
ASPath: []int{origins[1]}, NextHop: "198.51.100.2", LastUpdated: ts,
},
}); err != nil {
})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
s := New(failingASDetailsDB{db}, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s := New(failingASDetailsDB{db}, streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/prefix/192.0.2.0/24", nil))
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/prefix/"+testPrefixV4, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
}
page := rec.Body.String()
for _, asn := range origins {
info, ok := asinfo.Get(asn)
if !ok {
t.Fatalf("AS%d is not in the AS info data", asn)
}
if want := "(" + html.EscapeString(info.Handle) + ")"; !strings.Contains(page, want) {
want := "(" + html.EscapeString(info.Handle) + ")"
if !strings.Contains(page, want) {
t.Errorf("page does not show AS%d's handle %q", asn, want)
}
if want := html.EscapeString(info.Description); !strings.Contains(page, want) {
want = html.EscapeString(info.Description)
if !strings.Contains(page, want) {
t.Errorf("page does not show AS%d's description %q", asn, want)
}
}
@@ -282,14 +343,18 @@ const shortRequestDeadline = 50 * time.Millisecond
// written by the time limit middleware from its own goroutine while the
// handler could still be writing.
func TestRequestPastTimeLimitAnswers504(t *testing.T) {
s := New(nil, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
t.Parallel()
s := New(nil, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
waitForTimeLimit := func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}
// Registered the way setupRoutes registers pages and JSON routes.
s.router.Get("/slow-page", waitForTimeLimit)
s.router.Get("/slow.json", JSONValidationMiddleware(http.HandlerFunc(waitForTimeLimit)).ServeHTTP)
s.router.Get("/slow.json",
JSONValidationMiddleware(http.HandlerFunc(waitForTimeLimit)).ServeHTTP)
tests := []struct {
path string
@@ -300,14 +365,18 @@ func TestRequestPastTimeLimitAnswers504(t *testing.T) {
}
for _, tt := range tests {
ctx, cancel := context.WithTimeout(context.Background(), shortRequestDeadline)
ctx, cancel := context.WithTimeout(context.Background(),
shortRequestDeadline)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil).WithContext(ctx))
s.router.ServeHTTP(rec,
httptest.NewRequestWithContext(ctx, http.MethodGet, tt.path, nil))
cancel()
if rec.Code != http.StatusGatewayTimeout {
t.Errorf("%s: status %d, want %d; body %s", tt.path, rec.Code, http.StatusGatewayTimeout, rec.Body)
t.Errorf("%s: status %d, want %d; body %s",
tt.path, rec.Code, http.StatusGatewayTimeout, rec.Body)
}
if tt.wantJSONBody {
checkJSONErrorCode(t, tt.path, rec, http.StatusGatewayTimeout)
}
@@ -320,25 +389,32 @@ func TestRequestPastTimeLimitAnswers504(t *testing.T) {
// body. Before the fix status.json answered 408, and /api/v1/stats wrote
// nothing, which the JSON check on its route turned into 500.
func TestStatsHandlersAnswer504WhenDatabaseReadRunsOut(t *testing.T) {
t.Parallel()
release := make(chan struct{})
defer close(release)
db := blockingStatsDB{release: release}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
handlers := map[string]http.HandlerFunc{
"status.json": s.handleStatusJSON(),
"stats": s.handleStats(),
statusJSONHandlerName: s.handleStatusJSON(),
statsHandlerName: s.handleStats(),
}
for name, handler := range handlers {
ctx, cancel := context.WithTimeout(context.Background(), shortRequestDeadline)
ctx, cancel := context.WithTimeout(context.Background(),
shortRequestDeadline)
rec := httptest.NewRecorder()
handler(rec, httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx))
handler(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, "/", nil))
cancel()
if rec.Code != http.StatusGatewayTimeout {
t.Errorf("%s: status %d, want %d; body %s", name, rec.Code, http.StatusGatewayTimeout, rec.Body)
t.Errorf("%s: status %d, want %d; body %s",
name, rec.Code, http.StatusGatewayTimeout, rec.Body)
}
checkJSONErrorCode(t, name, rec, http.StatusGatewayTimeout)
}
}
@@ -360,25 +436,33 @@ func (slowDB) GetStatsContext(ctx context.Context) (database.Stats, error) {
return database.Stats{}, nil
}
func (slowDB) GetIPInfoContext(ctx context.Context, _ string) (*database.IPInfo, error) {
func (slowDB) GetIPInfoContext(
ctx context.Context, _ string,
) (*database.IPInfo, error) {
<-ctx.Done()
return nil, ctx.Err()
}
func (slowDB) GetASDetailsContext(ctx context.Context, asn int) (*database.ASN, []database.LiveRoute, error) {
func (slowDB) GetASDetailsContext(
ctx context.Context, asn int,
) (*database.ASN, []database.LiveRoute, error) {
<-ctx.Done()
return &database.ASN{ASN: asn}, nil, nil
}
func (slowDB) GetPrefixDetailsContext(ctx context.Context, _ string) ([]database.LiveRoute, map[int]database.ASN, error) {
func (slowDB) GetPrefixDetailsContext(
ctx context.Context, _ string,
) ([]database.LiveRoute, map[int]database.ASN, error) {
<-ctx.Done()
return nil, nil, database.ErrNoRoute
}
func (slowDB) GetRandomPrefixesByLengthContext(ctx context.Context, _, _, _ int) ([]database.LiveRoute, error) {
func (slowDB) GetRandomPrefixesByLengthContext(
ctx context.Context, _, _, _ int,
) ([]database.LiveRoute, error) {
<-ctx.Done()
return nil, nil
@@ -393,7 +477,10 @@ func (slowDB) GetRandomPrefixesByLengthContext(ctx context.Context, _, _, _ int)
// body. The health check is left out: it answers 503 when its own 2-second
// ping runs out.
func TestRoutesAnswer504WhenDatabaseReadIsCutOff(t *testing.T) {
s := New(slowDB{}, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
t.Parallel()
s := New(slowDB{}, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
tests := []struct {
path string
@@ -425,11 +512,15 @@ type slowSecondReadDB struct {
database.Store
}
func (slowSecondReadDB) GetASDetailsContext(_ context.Context, asn int) (*database.ASN, []database.LiveRoute, error) {
func (slowSecondReadDB) GetASDetailsContext(
_ context.Context, asn int,
) (*database.ASN, []database.LiveRoute, error) {
return &database.ASN{ASN: asn}, nil, nil
}
func (slowSecondReadDB) GetASPeersContext(ctx context.Context, _ int) ([]database.ASPeer, error) {
func (slowSecondReadDB) GetASPeersContext(
ctx context.Context, _ int,
) ([]database.ASPeer, error) {
<-ctx.Done()
return nil, ctx.Err()
@@ -439,7 +530,9 @@ func (slowSecondReadDB) GetStatsContext(_ context.Context) (database.Stats, erro
return database.Stats{}, nil
}
func (slowSecondReadDB) GetWHOISStats(ctx context.Context, _ time.Duration) (*database.WHOISStats, error) {
func (slowSecondReadDB) GetWHOISStats(
ctx context.Context, _ time.Duration,
) (*database.WHOISStats, error) {
<-ctx.Done()
return nil, ctx.Err()
@@ -462,7 +555,10 @@ func (idleASNFetcher) GetStats() ASNFetcherStats {
// message. Without the check after the second read, the AS page answers 504
// with an empty body and the stats routes answer 200 without the WHOIS stats.
func TestRoutesAnswer504WhenSecondDatabaseReadIsCutOff(t *testing.T) {
s := New(slowSecondReadDB{}, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
t.Parallel()
s := New(slowSecondReadDB{}, streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
s.SetASNFetcher(idleASNFetcher{})
tests := []struct {
@@ -482,27 +578,40 @@ func TestRoutesAnswer504WhenSecondDatabaseReadIsCutOff(t *testing.T) {
// checkRouteAnswers504 sends a request for path through the server's router
// with a short deadline and checks that it answers 504 with the time limit
// message: the JSON error body on JSON routes, the plain message on pages.
func checkRouteAnswers504(t *testing.T, s *Server, path string, wantJSONBody bool) {
func checkRouteAnswers504(
t *testing.T, s *Server, path string, wantJSONBody bool,
) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), shortRequestDeadline)
defer cancel()
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil).WithContext(ctx))
s.router.ServeHTTP(rec,
httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil))
if rec.Code != http.StatusGatewayTimeout {
t.Errorf("%s: status %d, want %d; body %s", path, rec.Code, http.StatusGatewayTimeout, rec.Body)
t.Errorf("%s: status %d, want %d; body %s",
path, rec.Code, http.StatusGatewayTimeout, rec.Body)
}
if wantJSONBody {
checkJSONErrorCode(t, path, rec, http.StatusGatewayTimeout)
} else if body := strings.TrimSpace(rec.Body.String()); body != requestTimeoutMessage {
return
}
body := strings.TrimSpace(rec.Body.String())
if body != requestTimeoutMessage {
t.Errorf("%s: body %q, want %q", path, body, requestTimeoutMessage)
}
}
// checkJSONErrorCode checks that rec holds the JSON error body with the given
// code.
func checkJSONErrorCode(t *testing.T, name string, rec *httptest.ResponseRecorder, want int) {
func checkJSONErrorCode(
t *testing.T, name string, rec *httptest.ResponseRecorder, want int,
) {
t.Helper()
var body struct {
@@ -511,13 +620,17 @@ func checkJSONErrorCode(t *testing.T, name string, rec *httptest.ResponseRecorde
Code int `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Errorf("%s: decoding the answer %q: %v", name, rec.Body, err)
return
}
if body.Status != "error" || body.Error.Code != want {
t.Errorf("%s: answer %s, want status \"error\" and error code %d", name, rec.Body, want)
if body.Status != jsonStatusError || body.Error.Code != want {
t.Errorf("%s: answer %s, want status \"error\" and error code %d",
name, rec.Body, want)
}
}
@@ -525,12 +638,15 @@ func checkJSONErrorCode(t *testing.T, name string, rec *httptest.ResponseRecorde
// current count.
func settledGoroutineCount() int {
prev := runtime.NumGoroutine()
for range 20 {
time.Sleep(10 * time.Millisecond)
cur := runtime.NumGoroutine()
if cur == prev {
return cur
}
prev = cur
}
@@ -543,6 +659,7 @@ func waitForGoroutines(target int) bool {
if runtime.NumGoroutine() <= target {
return true
}
time.Sleep(10 * time.Millisecond)
}
+23 -13
View File
@@ -15,6 +15,7 @@ import (
// responseWriter wraps http.ResponseWriter to capture the response
type responseWriter struct {
http.ResponseWriter
body *bytes.Buffer
statusCode int
written bool
@@ -87,8 +88,10 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
}
// Parse the original response
var originalResponse map[string]interface{}
if err := json.Unmarshal(rw.body.Bytes(), &originalResponse); err != nil {
var originalResponse map[string]any
err := json.Unmarshal(rw.body.Bytes(), &originalResponse)
if err != nil {
// If we can't parse it, just write original
w.WriteHeader(rw.statusCode)
_, _ = w.Write(rw.body.Bytes())
@@ -97,7 +100,7 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
}
// Add @meta field
originalResponse["@meta"] = map[string]interface{}{
originalResponse["@meta"] = map[string]any{
"time_zone": "UTC",
"api_version": 1,
"execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()),
@@ -160,8 +163,10 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
// For JSON responses, validate the JSON
if rw.body.Len() > 0 {
var testParse interface{}
if err := json.Unmarshal(rw.body.Bytes(), &testParse); err == nil {
var testParse any
err := json.Unmarshal(rw.body.Bytes(), &testParse)
if err == nil {
// Valid JSON, write it out
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(rw.statusCode)
@@ -180,9 +185,11 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
if statusCode == http.StatusOK {
statusCode = http.StatusInternalServerError
}
if deadlinePassed(r.Context()) {
statusCode = http.StatusGatewayTimeout
}
w.WriteHeader(statusCode)
errorMsg := "Internal server error"
@@ -190,11 +197,11 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
errorMsg = requestTimeoutMessage
}
response := map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"msg": errorMsg,
"code": statusCode,
response := map[string]any{
jsonKeyStatus: jsonStatusError,
jsonKeyError: map[string]any{
jsonKeyMsg: errorMsg,
jsonKeyCode: statusCode,
},
}
_ = json.NewEncoder(w).Encode(response)
@@ -204,6 +211,7 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
// statusWriter wraps http.ResponseWriter to capture the status code
type statusWriter struct {
http.ResponseWriter
statusCode int
written bool
}
@@ -213,6 +221,7 @@ func (sw *statusWriter) WriteHeader(statusCode int) {
sw.statusCode = statusCode
sw.written = true
}
sw.ResponseWriter.WriteHeader(statusCode)
}
@@ -252,11 +261,12 @@ func RequestLoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handle
// Slow query threshold (1 second)
const slowQueryThreshold = 1 * time.Second
if sw.statusCode >= http.StatusInternalServerError {
switch {
case sw.statusCode >= http.StatusInternalServerError:
logLevel = slog.LevelError
} else if sw.statusCode >= http.StatusBadRequest {
case sw.statusCode >= http.StatusBadRequest:
logLevel = slog.LevelWarn
} else if duration >= slowQueryThreshold {
case duration >= slowQueryThreshold:
logLevel = slog.LevelWarn
}
+4 -1
View File
@@ -27,7 +27,10 @@ func (s *Server) setupRoutes() {
r.Get("/", s.handleIndex())
r.Get("/status", s.handleStatusHTML())
r.Get("/status.json", JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP)
r.Get("/.well-known/healthcheck.json", JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP)
r.Get(
"/.well-known/healthcheck.json",
JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP,
)
// AS and prefix detail pages
r.Get("/as/{asn}", s.handleASDetail())
+14 -5
View File
@@ -3,6 +3,7 @@ package server
import (
"context"
"errors"
"fmt"
"net"
"net/http"
@@ -42,7 +43,12 @@ type Server struct {
}
// New creates a new HTTP server
func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger, cfg *config.Config) *Server {
func New(
db database.Store,
streamer *streamer.Streamer,
logger *logger.Logger,
cfg *config.Config,
) *Server {
s := &Server{
db: db,
streamer: streamer,
@@ -57,8 +63,8 @@ func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger,
// Start listens on the configured port and serves HTTP in the background.
// It returns an error if it cannot listen, for example because the port is
// already in use.
func (s *Server) Start() error {
// already in use. ctx bounds only opening the port, not serving.
func (s *Server) Start(ctx context.Context) error {
const (
network = "tcp"
readHeaderTimeout = 40 * time.Second
@@ -78,7 +84,9 @@ func (s *Server) Start() error {
s.logger.Info("Starting HTTP server", "port", s.port, "addr", s.srv.Addr)
listener, err := net.Listen(network, s.srv.Addr)
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(ctx, network, s.srv.Addr)
if err != nil {
return fmt.Errorf("starting HTTP server: %w", err)
}
@@ -86,7 +94,8 @@ func (s *Server) Start() error {
s.logger.Info("HTTP server listening", "addr", listener.Addr().String())
go func() {
if err := s.srv.Serve(listener); err != nil && err != http.ErrServerClosed {
err := s.srv.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Error("HTTP server error", "error", err)
}
}()
+20 -6
View File
@@ -1,4 +1,4 @@
package server
package server_test
import (
"context"
@@ -8,6 +8,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/server"
"git.eeqj.de/sneak/routewatch/internal/streamer"
)
@@ -16,18 +17,31 @@ import (
// listened in a goroutine, only logged the failure and returned nil, so the
// daemon kept running without its web server.
func TestStartFailsWhenPortIsHeld(t *testing.T) {
held, err := net.Listen("tcp", ":0")
t.Parallel()
var listenConfig net.ListenConfig
held, err := listenConfig.Listen(t.Context(), "tcp", ":0")
if err != nil {
t.Fatalf("failed to open the test listener: %v", err)
}
defer func() { _ = held.Close() }()
port := held.Addr().(*net.TCPAddr).Port
addr, ok := held.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("test listener address %v is not a TCP address", held.Addr())
}
port := addr.Port
log := logger.New()
s := New(nil, streamer.New(log, metrics.New()), log, &config.Config{Port: port})
s := server.New(nil, streamer.New(log, metrics.New()), log,
&config.Config{Port: port})
defer func() { _ = s.Stop(context.Background()) }()
if err := s.Start(); err == nil {
t.Fatalf("Start returned nil while port %d was held by another listener", port)
err = s.Start(t.Context())
if err == nil {
t.Fatalf("Start returned nil while port %d was held by another listener",
port)
}
}