WIP server lint fixes
check / check (push) Canceled after 0s

Model: opus-5-5
This commit is contained in:
2026-10-14 13:14:58 +00:00
parent 465e3ea292
commit 5c038b7e82
5 changed files with 666 additions and 564 deletions
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,7 @@ import (
// path and would panic if called. // path and would panic if called.
type blockingStatsDB struct { type blockingStatsDB struct {
database.Store database.Store
release chan struct{} release chan struct{}
} }
@@ -39,6 +40,8 @@ func (d blockingStatsDB) GetStatsContext(_ context.Context) (database.Stats, err
// value. Before the fix the per-request goroutine sent on an unbuffered channel // 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 // that nothing received once the timeout won, so it blocked forever and every
// poll leaked one goroutine. // poll leaked one goroutine.
//
//nolint:paralleltest // counts goroutines, so no other test may run alongside
func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) { func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) {
release := make(chan struct{}) release := make(chan struct{})
db := blockingStatsDB{release: release} db := blockingStatsDB{release: release}
@@ -58,7 +61,7 @@ func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) {
for _, handler := range handlers { for _, handler := range handlers {
for range iterations { for range iterations {
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) 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) handler(httptest.NewRecorder(), req)
cancel() cancel()
} }
@@ -83,27 +86,33 @@ func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) {
// The oldest and newest route times still come from one-row lookups at the ends // 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. // of an index; with the database closed they are left out of the answer.
func TestStatsHandlersAnswerFromMemory(t *testing.T) { func TestStatsHandlersAnswerFromMemory(t *testing.T) {
t.Parallel()
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New()) db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil { if err != nil {
t.Fatalf("database.New: %v", err) t.Fatalf("database.New: %v", err)
} }
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) 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, 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", OriginASN: 64500, PeerIP: "192.0.2.1", ASPath: []int{64500},
LastUpdated: ts, NextHop: "192.0.2.1", LastUpdated: ts,
}, },
{ {
ID: uuid.New(), Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6, ID: uuid.New(), Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6,
OriginASN: 64501, PeerIP: "2001:db8::1", ASPath: []int{64501}, NextHop: "2001:db8::1", OriginASN: 64501, PeerIP: "2001:db8::1", ASPath: []int{64501},
LastUpdated: ts, NextHop: "2001:db8::1", LastUpdated: ts,
}, },
}); err != nil { })
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err) t.Fatalf("UpsertLiveRouteBatch: %v", err)
} }
if err := db.Close(); err != nil {
err = db.Close()
if err != nil {
t.Fatalf("Close: %v", err) t.Fatalf("Close: %v", err)
} }
@@ -115,35 +124,45 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
for name, handler := range handlers { for name, handler := range handlers {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler(rec, httptest.NewRequest(http.MethodGet, "/", nil)) handler(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil))
if rec.Code != http.StatusOK { 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 continue
} }
//nolint:tagliatelle // field names are the existing JSON API
var body struct { var body struct {
Data struct { Data struct {
IPv4Routes int `json:"ipv4_routes"` IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"` IPv6Routes int `json:"ipv6_routes"`
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"` IPv4Distribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"` IPv6Distribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
} `json:"data"` } `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) t.Fatalf("%s: decoding the answer: %v", name, err)
} }
if body.Data.IPv4Routes != 1 || body.Data.IPv6Routes != 1 { 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}} wantV4 := []database.PrefixDistribution{{MaskLength: 24, Count: 1}}
if !slices.Equal(body.Data.IPv4PrefixDistribution, wantV4) { if !slices.Equal(body.Data.IPv4Distribution, wantV4) {
t.Errorf("%s: IPv4 distribution = %v, want %v", name, body.Data.IPv4PrefixDistribution, wantV4) t.Errorf("%s: IPv4 distribution = %v, want %v",
name, body.Data.IPv4Distribution, wantV4)
} }
wantV6 := []database.PrefixDistribution{{MaskLength: 32, Count: 1}} wantV6 := []database.PrefixDistribution{{MaskLength: 32, Count: 1}}
if !slices.Equal(body.Data.IPv6PrefixDistribution, wantV6) { if !slices.Equal(body.Data.IPv6Distribution, wantV6) {
t.Errorf("%s: IPv6 distribution = %v, want %v", name, body.Data.IPv6PrefixDistribution, wantV6) t.Errorf("%s: IPv6 distribution = %v, want %v",
name, body.Data.IPv6Distribution, wantV6)
} }
} }
} }
@@ -152,12 +171,15 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
// current count. // current count.
func settledGoroutineCount() int { func settledGoroutineCount() int {
prev := runtime.NumGoroutine() prev := runtime.NumGoroutine()
for range 20 { for range 20 {
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
cur := runtime.NumGoroutine() cur := runtime.NumGoroutine()
if cur == prev { if cur == prev {
return cur return cur
} }
prev = cur prev = cur
} }
@@ -170,6 +192,7 @@ func waitForGoroutines(target int) bool {
if runtime.NumGoroutine() <= target { if runtime.NumGoroutine() <= target {
return true return true
} }
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
} }
+36 -21
View File
@@ -14,6 +14,7 @@ import (
// responseWriter wraps http.ResponseWriter to capture the response // responseWriter wraps http.ResponseWriter to capture the response
type responseWriter struct { type responseWriter struct {
http.ResponseWriter http.ResponseWriter
body *bytes.Buffer body *bytes.Buffer
statusCode int statusCode int
written bool written bool
@@ -86,8 +87,10 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
} }
// Parse the original response // Parse the original response
var originalResponse map[string]interface{} var originalResponse map[string]any
if err := json.Unmarshal(rw.body.Bytes(), &originalResponse); err != nil {
err := json.Unmarshal(rw.body.Bytes(), &originalResponse)
if err != nil {
// If we can't parse it, just write original // If we can't parse it, just write original
w.WriteHeader(rw.statusCode) w.WriteHeader(rw.statusCode)
_, _ = w.Write(rw.body.Bytes()) _, _ = w.Write(rw.body.Bytes())
@@ -96,7 +99,7 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
} }
// Add @meta field // Add @meta field
originalResponse["@meta"] = map[string]interface{}{ originalResponse["@meta"] = map[string]any{
"time_zone": "UTC", "time_zone": "UTC",
"api_version": 1, "api_version": 1,
"execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()), "execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()),
@@ -112,6 +115,7 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
// timeoutWriter wraps ResponseWriter to prevent concurrent writes after timeout // timeoutWriter wraps ResponseWriter to prevent concurrent writes after timeout
type timeoutWriter struct { type timeoutWriter struct {
http.ResponseWriter http.ResponseWriter
mu sync.Mutex mu sync.Mutex
written bool written bool
header http.Header // cached header to prevent concurrent access header http.Header // cached header to prevent concurrent access
@@ -158,6 +162,7 @@ func (tw *timeoutWriter) Header() http.Header {
func (tw *timeoutWriter) markWritten() { func (tw *timeoutWriter) markWritten() {
tw.mu.Lock() tw.mu.Lock()
defer tw.mu.Unlock() defer tw.mu.Unlock()
tw.written = true tw.written = true
} }
@@ -168,7 +173,10 @@ func (tw *timeoutWriter) markWritten() {
// The timeout parameter specifies the maximum duration allowed for request processing. // The timeout parameter specifies the maximum duration allowed for request processing.
// The returned middleware handles panics from the wrapped handler by re-panicking // The returned middleware handles panics from the wrapped handler by re-panicking
// after cleanup, and prevents concurrent writes to the response after timeout occurs. // after cleanup, and prevents concurrent writes to the response after timeout occurs.
func TimeoutMiddleware(timeout time.Duration, logger *slog.Logger) func(http.Handler) http.Handler { func TimeoutMiddleware(
timeout time.Duration,
logger *slog.Logger,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startTime := time.Now() startTime := time.Now()
@@ -182,7 +190,7 @@ func TimeoutMiddleware(timeout time.Duration, logger *slog.Logger) func(http.Han
} }
done := make(chan struct{}) done := make(chan struct{})
panicChan := make(chan interface{}, 1) panicChan := make(chan any, 1)
go func() { go func() {
defer func() { defer func() {
@@ -202,6 +210,7 @@ func TimeoutMiddleware(timeout time.Duration, logger *slog.Logger) func(http.Han
return return
case <-ctx.Done(): case <-ctx.Done():
tw.markWritten() // Prevent the handler from writing after timeout tw.markWritten() // Prevent the handler from writing after timeout
execTime := time.Since(startTime) execTime := time.Since(startTime)
// Log the timeout as a warning // Log the timeout as a warning
@@ -219,13 +228,13 @@ func TimeoutMiddleware(timeout time.Duration, logger *slog.Logger) func(http.Han
w.WriteHeader(http.StatusRequestTimeout) w.WriteHeader(http.StatusRequestTimeout)
tw.mu.Unlock() tw.mu.Unlock()
response := map[string]interface{}{ response := map[string]any{
"status": "error", jsonKeyStatus: jsonStatusError,
"error": map[string]interface{}{ jsonKeyError: map[string]any{
"msg": "Request timeout", jsonKeyMsg: "Request timeout",
"code": http.StatusRequestTimeout, jsonKeyCode: http.StatusRequestTimeout,
}, },
"@meta": map[string]interface{}{ "@meta": map[string]any{
"time_zone": "UTC", "time_zone": "UTC",
"api_version": 1, "api_version": 1,
"execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()), "execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()),
@@ -273,8 +282,10 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
// For JSON responses, validate the JSON // For JSON responses, validate the JSON
if rw.body.Len() > 0 { if rw.body.Len() > 0 {
var testParse interface{} var testParse any
if err := json.Unmarshal(rw.body.Bytes(), &testParse); err == nil {
err := json.Unmarshal(rw.body.Bytes(), &testParse)
if err == nil {
// Valid JSON, write it out // Valid JSON, write it out
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(rw.statusCode) w.WriteHeader(rw.statusCode)
@@ -293,6 +304,7 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
if statusCode == http.StatusOK { if statusCode == http.StatusOK {
statusCode = http.StatusInternalServerError statusCode = http.StatusInternalServerError
} }
w.WriteHeader(statusCode) w.WriteHeader(statusCode)
errorMsg := "Internal server error" errorMsg := "Internal server error"
@@ -300,11 +312,11 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
errorMsg = "Request timeout" errorMsg = "Request timeout"
} }
response := map[string]interface{}{ response := map[string]any{
"status": "error", jsonKeyStatus: jsonStatusError,
"error": map[string]interface{}{ jsonKeyError: map[string]any{
"msg": errorMsg, jsonKeyMsg: errorMsg,
"code": statusCode, jsonKeyCode: statusCode,
}, },
} }
_ = json.NewEncoder(w).Encode(response) _ = json.NewEncoder(w).Encode(response)
@@ -314,6 +326,7 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
// statusWriter wraps http.ResponseWriter to capture the status code // statusWriter wraps http.ResponseWriter to capture the status code
type statusWriter struct { type statusWriter struct {
http.ResponseWriter http.ResponseWriter
statusCode int statusCode int
written bool written bool
} }
@@ -323,6 +336,7 @@ func (sw *statusWriter) WriteHeader(statusCode int) {
sw.statusCode = statusCode sw.statusCode = statusCode
sw.written = true sw.written = true
} }
sw.ResponseWriter.WriteHeader(statusCode) sw.ResponseWriter.WriteHeader(statusCode)
} }
@@ -362,11 +376,12 @@ func RequestLoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handle
// Slow query threshold (1 second) // Slow query threshold (1 second)
const slowQueryThreshold = 1 * time.Second const slowQueryThreshold = 1 * time.Second
if sw.statusCode >= http.StatusInternalServerError { switch {
case sw.statusCode >= http.StatusInternalServerError:
logLevel = slog.LevelError logLevel = slog.LevelError
} else if sw.statusCode >= http.StatusBadRequest { case sw.statusCode >= http.StatusBadRequest:
logLevel = slog.LevelWarn logLevel = slog.LevelWarn
} else if duration >= slowQueryThreshold { case duration >= slowQueryThreshold:
logLevel = slog.LevelWarn logLevel = slog.LevelWarn
} }
+6 -1
View File
@@ -16,7 +16,9 @@ func (s *Server) setupRoutes() {
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
const requestTimeout = 30 * time.Second // Increased from 8s for slow queries const requestTimeout = 30 * time.Second // Increased from 8s for slow queries
r.Use(TimeoutMiddleware(requestTimeout, s.logger.Logger)) r.Use(TimeoutMiddleware(requestTimeout, s.logger.Logger))
r.Use(JSONResponseMiddleware) r.Use(JSONResponseMiddleware)
@@ -24,7 +26,10 @@ func (s *Server) setupRoutes() {
r.Get("/", s.handleIndex()) r.Get("/", s.handleIndex())
r.Get("/status", s.handleStatusHTML()) r.Get("/status", s.handleStatusHTML())
r.Get("/status.json", JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP) 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 // AS and prefix detail pages
r.Get("/as/{asn}", s.handleASDetail()) r.Get("/as/{asn}", s.handleASDetail())
+10 -2
View File
@@ -3,6 +3,7 @@ package server
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -40,7 +41,12 @@ type Server struct {
} }
// New creates a new HTTP server // 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{ s := &Server{
db: db, db: db,
streamer: streamer, streamer: streamer,
@@ -76,7 +82,9 @@ func (s *Server) Start() error {
// Start in goroutine but log when actually listening // Start in goroutine but log when actually listening
go func() { go func() {
s.logger.Info("HTTP server listening", "addr", s.srv.Addr) s.logger.Info("HTTP server listening", "addr", s.srv.Addr)
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
err := s.srv.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Error("HTTP server error", "error", err) s.logger.Error("HTTP server error", "error", err)
} }
}() }()