1 Commits
Author SHA1 Message Date
sneak bc7c5e18a5 Answer 504, not 408, when a request outlasts its time limit (closes #70)
check / check (push) Waiting to run
The hand-written time limit middleware answered 408 and wrote that
answer from its own goroutine while the handler could still be writing.
It is replaced by chi's middleware.Timeout, placed after
JSONResponseMiddleware so its 504 is not lost behind a 200.

When a handler's database read or other lookup fails, it asks
deadlinePassed whether the request's time limit passed and, if so,
answers 504 instead of its 500 or 404. JSONValidationMiddleware answers
504 with the JSON error body when the limit has passed and the handler
wrote nothing. status.json and /api/v1/stats answer 504 when their
4-second database read runs out.

Model: opus-5-5
2026-10-14 15:37:24 +00:00
15 changed files with 296 additions and 468 deletions
+1 -4
View File
@@ -141,10 +141,7 @@ routewatch/
- **Batched Writes**: All database operations are batched for performance - **Batched Writes**: All database operations are batched for performance
- **Backpressure**: Probabilistic message dropping when queues exceed 50% capacity - **Backpressure**: Probabilistic message dropping when queues exceed 50% capacity
- **Graceful Shutdown**: 60-second timeout, flushes all pending batches - **Graceful Shutdown**: 60-second timeout, flushes all pending batches
- **Reconnection**: Any end of the stream other than shutdown, including RIS Live - **Reconnection**: Exponential backoff (5s-320s) with reset after 30s of stable connection
ending the response normally, marks the feed disconnected and reconnects with
exponential backoff (5s-320s). The backoff resets after a stream that lasted
more than 30s. A stream with no line for 60s is dropped and reconnected.
- **IP Lookup**: the most specific live route is found by looking up the - **IP Lookup**: the most specific live route is found by looking up the
address's prefix at each mask length, longest first, in the prefix index address's prefix at each mask length, longest first, in the prefix index
(at most 25 lookups for IPv4, 113 for IPv6); prefixes are stored in the (at most 25 lookups for IPv4, 113 for IPv6); prefixes are stored in the
+7 -14
View File
@@ -1632,17 +1632,16 @@ func (d *Database) GetASPeersContext(ctx context.Context, asn int) ([]ASPeer, er
} }
// GetPrefixDetails returns detailed information about a prefix // GetPrefixDetails returns detailed information about a prefix
func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, map[int]ASN, error) { func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, error) {
return d.GetPrefixDetailsContext(context.Background(), prefix) return d.GetPrefixDetailsContext(context.Background(), prefix)
} }
// GetPrefixDetailsContext returns the live routes for a prefix and, keyed by // GetPrefixDetailsContext returns detailed information about a prefix with context support
// AS number, the handle and description of each AS that originates it. func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, error) {
func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, map[int]ASN, error) {
// Determine if it's IPv4 or IPv6 by parsing the prefix // Determine if it's IPv4 or IPv6 by parsing the prefix
_, ipnet, err := net.ParseCIDR(prefix) _, ipnet, err := net.ParseCIDR(prefix)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("invalid prefix format: %w", err) return nil, fmt.Errorf("invalid prefix format: %w", err)
} }
tableName := "live_routes_v4" tableName := "live_routes_v4"
@@ -1664,12 +1663,11 @@ func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) (
rows, err := d.db.QueryContext(ctx, query, prefix) rows, err := d.db.QueryContext(ctx, query, prefix)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to query prefix details: %w", err) return nil, fmt.Errorf("failed to query prefix details: %w", err)
} }
defer func() { _ = rows.Close() }() defer func() { _ = rows.Close() }()
var routes []LiveRoute var routes []LiveRoute
origins := make(map[int]ASN)
for rows.Next() { for rows.Next() {
var route LiveRoute var route LiveRoute
var pathJSON string var pathJSON string
@@ -1692,18 +1690,13 @@ func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) (
route.IPVersion = ipVersion route.IPVersion = ipVersion
route.MaskLength, _ = ipnet.Mask.Size() route.MaskLength, _ = ipnet.Mask.Size()
routes = append(routes, route) routes = append(routes, route)
origins[route.OriginASN] = ASN{
ASN: route.OriginASN,
Handle: handle.String,
Description: description.String,
}
} }
if len(routes) == 0 { if len(routes) == 0 {
return nil, nil, fmt.Errorf("%w: %s", ErrNoRoute, prefix) return nil, fmt.Errorf("%w: %s", ErrNoRoute, prefix)
} }
return routes, origins, nil return routes, nil
} }
// GetRandomPrefixesByLength returns a random sample of prefixes with the specified mask length // GetRandomPrefixesByLength returns a random sample of prefixes with the specified mask length
+2 -2
View File
@@ -76,8 +76,8 @@ type Store interface {
GetASDetailsContext(ctx context.Context, asn int) (*ASN, []LiveRoute, error) GetASDetailsContext(ctx context.Context, asn int) (*ASN, []LiveRoute, error)
GetASPeers(asn int) ([]ASPeer, error) GetASPeers(asn int) ([]ASPeer, error)
GetASPeersContext(ctx context.Context, asn int) ([]ASPeer, error) GetASPeersContext(ctx context.Context, asn int) ([]ASPeer, error)
GetPrefixDetails(prefix string) ([]LiveRoute, map[int]ASN, error) GetPrefixDetails(prefix string) ([]LiveRoute, error)
GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, map[int]ASN, error) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, error)
GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error) GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error)
GetRandomPrefixesByLengthContext(ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error) GetRandomPrefixesByLengthContext(ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error)
+3 -5
View File
@@ -252,15 +252,13 @@ func (m *mockStore) GetASDetailsContext(ctx context.Context, asn int) (*database
} }
// GetPrefixDetails mock implementation // GetPrefixDetails mock implementation
func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, map[int]database.ASN, error) { func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, error) {
// Return empty routes for now // Return empty routes for now
return []database.LiveRoute{}, map[int]database.ASN{}, nil return []database.LiveRoute{}, nil
} }
// GetPrefixDetailsContext mock implementation with context support // GetPrefixDetailsContext mock implementation with context support
func (m *mockStore) GetPrefixDetailsContext( func (m *mockStore) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]database.LiveRoute, error) {
ctx context.Context, prefix string,
) ([]database.LiveRoute, map[int]database.ASN, error) {
return m.GetPrefixDetails(prefix) return m.GetPrefixDetails(prefix)
} }
-5
View File
@@ -20,8 +20,6 @@ const (
debugInterval = 60 * time.Second debugInterval = 60 * time.Second
// bytesPerMB is bytes per megabyte // bytesPerMB is bytes per megabyte
bytesPerMB = 1024 * 1024 bytesPerMB = 1024 * 1024
// failureExitCode is the exit status when RouteWatch stops on an error
failureExitCode = 1
) )
// logDebugStats logs goroutine count and memory usage // logDebugStats logs goroutine count and memory usage
@@ -85,9 +83,6 @@ func CLIEntry() {
go func() { go func() {
if err := rw.Run(ctx); err != nil { if err := rw.Run(ctx); err != nil {
logger.Error("RouteWatch error", "error", err) logger.Error("RouteWatch error", "error", err)
if err := shutdowner.Shutdown(fx.ExitCode(failureExitCode)); err != nil {
logger.Error("Failed to shutdown gracefully", "error", err)
}
} }
}() }()
+1 -1
View File
@@ -170,7 +170,7 @@ func TestPrefixHandlerAppliesEachPeersLastUpdateInABatch(t *testing.T) {
} }
ctx := context.Background() ctx := context.Background()
routes, _, err := db.GetPrefixDetailsContext(ctx, prefix) routes, err := db.GetPrefixDetailsContext(ctx, prefix)
if err != nil && !errors.Is(err, database.ErrNoRoute) { if err != nil && !errors.Is(err, database.ErrNoRoute) {
t.Fatalf("GetPrefixDetailsContext: %v", err) t.Fatalf("GetPrefixDetailsContext: %v", err)
} }
+66 -15
View File
@@ -201,12 +201,16 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
select { select {
case <-ctx.Done(): case <-ctx.Done():
s.logger.Error("Database stats timeout in status.json") s.logger.Error("Database stats timeout in status.json")
writeJSONError(w, http.StatusRequestTimeout, "Database timeout") writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
return return
case err := <-errChan: case err := <-errChan:
s.logger.Error("Failed to get database stats", "error", err) s.logger.Error("Failed to get database stats", "error", err)
if deadlinePassed(ctx) {
writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
} else {
writeJSONError(w, http.StatusInternalServerError, err.Error()) writeJSONError(w, http.StatusInternalServerError, err.Error())
}
return return
case dbStats = <-statsChan: case dbStats = <-statsChan:
@@ -386,7 +390,7 @@ func (s *Server) handleStats() http.HandlerFunc {
// Check if context is already cancelled // Check if context is already cancelled
select { select {
case <-ctx.Done(): case <-ctx.Done():
http.Error(w, "Request timeout", http.StatusRequestTimeout) writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
return return
default: default:
@@ -415,11 +419,16 @@ func (s *Server) handleStats() http.HandlerFunc {
select { select {
case <-ctx.Done(): case <-ctx.Done():
s.logger.Error("Database stats timeout") s.logger.Error("Database stats timeout")
// Don't write response here - timeout middleware already handles it writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
return return
case err := <-errChan: case err := <-errChan:
s.logger.Error("Failed to get database stats", "error", err) s.logger.Error("Failed to get database stats", "error", err)
if deadlinePassed(ctx) {
writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
} else {
writeJSONError(w, http.StatusInternalServerError, err.Error()) writeJSONError(w, http.StatusInternalServerError, err.Error())
}
return return
case dbStats = <-statsChan: case dbStats = <-statsChan:
@@ -622,6 +631,13 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
} }
} }
// The time limit passed while resolving host names.
if deadlinePassed(ctx) {
writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
return
}
if len(ipsToLookup) == 0 { if len(ipsToLookup) == 0 {
writeJSONError(w, http.StatusBadRequest, "No valid IPs or hostnames provided") writeJSONError(w, http.StatusBadRequest, "No valid IPs or hostnames provided")
@@ -664,6 +680,13 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
} }
} }
// The time limit passed while looking up the IPs.
if deadlinePassed(ctx) {
writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
return
}
// Return response (even if no results, include errors) // Return response (even if no results, include errors)
if len(response.Results) == 0 && len(response.Errors) > 0 { if len(response.Results) == 0 && len(response.Errors) > 0 {
writeJSONError(w, http.StatusNotFound, "No routes found: "+response.Errors[0]) writeJSONError(w, http.StatusNotFound, "No routes found: "+response.Errors[0])
@@ -703,9 +726,12 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn) asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn)
if err != nil { if err != nil {
if errors.Is(err, database.ErrNoRoute) { switch {
case errors.Is(err, database.ErrNoRoute):
writeJSONError(w, http.StatusNotFound, err.Error()) writeJSONError(w, http.StatusNotFound, err.Error())
} else { case deadlinePassed(r.Context()):
writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
default:
writeJSONError(w, http.StatusInternalServerError, err.Error()) writeJSONError(w, http.StatusInternalServerError, err.Error())
} }
@@ -759,11 +785,14 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
} }
prefix := parsed.Masked().String() prefix := parsed.Masked().String()
routes, _, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil { if err != nil {
if errors.Is(err, database.ErrNoRoute) { switch {
case errors.Is(err, database.ErrNoRoute):
writeJSONError(w, http.StatusNotFound, err.Error()) writeJSONError(w, http.StatusNotFound, err.Error())
} else { case deadlinePassed(r.Context()):
writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
default:
writeJSONError(w, http.StatusInternalServerError, err.Error()) writeJSONError(w, http.StatusInternalServerError, err.Error())
} }
@@ -803,9 +832,12 @@ func (s *Server) handleASDetail() http.HandlerFunc {
asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn) asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn)
if err != nil { if err != nil {
if errors.Is(err, database.ErrNoRoute) { switch {
case errors.Is(err, database.ErrNoRoute):
http.Error(w, "AS not found", http.StatusNotFound) http.Error(w, "AS not found", http.StatusNotFound)
} else { case deadlinePassed(r.Context()):
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
default:
s.logger.Error("Failed to get AS details", "error", err) s.logger.Error("Failed to get AS details", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
} }
@@ -930,11 +962,14 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
} }
prefix := parsed.Masked().String() prefix := parsed.Masked().String()
routes, originASNs, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil { if err != nil {
if errors.Is(err, database.ErrNoRoute) { switch {
case errors.Is(err, database.ErrNoRoute):
http.Error(w, "Prefix not found", http.StatusNotFound) http.Error(w, "Prefix not found", http.StatusNotFound)
} else { case deadlinePassed(r.Context()):
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
default:
s.logger.Error("Failed to get prefix details", "error", err) s.logger.Error("Failed to get prefix details", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
} }
@@ -952,10 +987,18 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
originMap := make(map[int]*ASNInfo) originMap := make(map[int]*ASNInfo)
for _, route := range routes { for _, route := range routes {
if _, exists := originMap[route.OriginASN]; !exists { if _, exists := originMap[route.OriginASN]; !exists {
// Get AS info from database
asInfo, _, _ := s.db.GetASDetailsContext(r.Context(), route.OriginASN)
handle := ""
description := ""
if asInfo != nil {
handle = asInfo.Handle
description = asInfo.Description
}
originMap[route.OriginASN] = &ASNInfo{ originMap[route.OriginASN] = &ASNInfo{
ASN: route.OriginASN, ASN: route.OriginASN,
Handle: originASNs[route.OriginASN].Handle, Handle: handle,
Description: originASNs[route.OriginASN].Description, Description: description,
PeerCount: 0, PeerCount: 0,
} }
} }
@@ -1080,8 +1123,12 @@ func (s *Server) handlePrefixLength() http.HandlerFunc {
const maxPrefixes = 500 const maxPrefixes = 500
prefixes, err := s.db.GetRandomPrefixesByLengthContext(r.Context(), maskLength, ipVersion, maxPrefixes) prefixes, err := s.db.GetRandomPrefixesByLengthContext(r.Context(), maskLength, ipVersion, maxPrefixes)
if err != nil { if err != nil {
if deadlinePassed(r.Context()) {
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
} else {
s.logger.Error("Failed to get prefixes by length", "error", err) s.logger.Error("Failed to get prefixes by length", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return return
} }
@@ -1171,8 +1218,12 @@ func (s *Server) handlePrefixLength6() http.HandlerFunc {
const maxPrefixes = 500 const maxPrefixes = 500
prefixes, err := s.db.GetRandomPrefixesByLengthContext(r.Context(), maskLength, ipVersion, maxPrefixes) prefixes, err := s.db.GetRandomPrefixesByLengthContext(r.Context(), maskLength, ipVersion, maxPrefixes)
if err != nil { if err != nil {
if deadlinePassed(r.Context()) {
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
} else {
s.logger.Error("Failed to get prefixes by length", "error", err) s.logger.Error("Failed to get prefixes by length", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return return
} }
+157 -54
View File
@@ -3,13 +3,10 @@ package server
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"html"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"runtime" "runtime"
"slices" "slices"
"strings"
"testing" "testing"
"time" "time"
@@ -18,7 +15,6 @@ import (
"git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics" "git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/streamer" "git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -204,69 +200,176 @@ func TestPrefixDetailHandlersParsePrefix(t *testing.T) {
} }
} }
// failingASDetailsDB wraps a real store and fails every AS details lookup. // shortRequestDeadline is the deadline the tests below give a request. The
type failingASDetailsDB struct { // server's 30-second limit and the stats handlers' 4-second limit inherit it,
// so the tests do not wait for either.
const shortRequestDeadline = 50 * time.Millisecond
// TestRequestPastTimeLimitAnswers504 sends a request through the server's
// router to a page and to a JSON route whose handlers wait for the time limit
// to pass and return without answering. The page answers 504; the JSON route
// answers 504 with the JSON error body. Before the fix both answered 408,
// 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{})
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)
tests := []struct {
path string
wantJSONBody bool
}{
{path: "/slow-page"},
{path: "/slow.json", wantJSONBody: true},
}
for _, tt := range tests {
ctx, cancel := context.WithTimeout(context.Background(), shortRequestDeadline)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil).WithContext(ctx))
cancel()
if rec.Code != http.StatusGatewayTimeout {
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)
}
}
}
// TestStatsHandlersAnswer504WhenDatabaseReadRunsOut calls each stats handler
// with a store that does not answer before the handler's 4-second limit runs
// out, and checks that the handler itself answers 504 with the JSON error
// 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) {
release := make(chan struct{})
defer close(release)
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(),
}
for name, handler := range handlers {
ctx, cancel := context.WithTimeout(context.Background(), shortRequestDeadline)
rec := httptest.NewRecorder()
handler(rec, httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx))
cancel()
if rec.Code != http.StatusGatewayTimeout {
t.Errorf("%s: status %d, want %d; body %s", name, rec.Code, http.StatusGatewayTimeout, rec.Body)
}
checkJSONErrorCode(t, name, rec, http.StatusGatewayTimeout)
}
}
// slowDB embeds database.Store (left nil) and overrides the reads the routes
// in TestRoutesAnswer504WhenDatabaseReadIsCutOff make. Each read waits until
// its context ends and returns the context's error, as a slow database read
// cut off by a time limit does.
type slowDB struct {
database.Store database.Store
} }
func (failingASDetailsDB) GetASDetailsContext(_ context.Context, _ int) (*database.ASN, []database.LiveRoute, error) { func (slowDB) GetStatsContext(ctx context.Context) (database.Stats, error) {
return nil, nil, errors.New("AS details lookup failed") <-ctx.Done()
return database.Stats{}, ctx.Err()
} }
// TestPrefixDetailShowsOriginNames checks that the prefix page shows each func (slowDB) GetIPInfoContext(ctx context.Context, _ string) (*database.IPInfo, error) {
// origin AS's handle and description without looking up the AS's details, <-ctx.Done()
// which also read every route the AS originates. Before the fix the page did
// that lookup once per origin, so with it failing the names were missing
// (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) {
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) return nil, ctx.Err()
origins := []int{15169, 13335}
for _, asn := range origins {
if _, err := db.GetOrCreateASN(asn, ts); err != nil {
t.Fatalf("GetOrCreateASN(%d): %v", asn, err)
}
}
if 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: "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,
},
}); err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
} }
s := New(failingASDetailsDB{db}, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{}) func (slowDB) GetASDetailsContext(ctx context.Context, _ int) (*database.ASN, []database.LiveRoute, error) {
<-ctx.Done()
return nil, nil, ctx.Err()
}
func (slowDB) GetPrefixDetailsContext(ctx context.Context, _ string) ([]database.LiveRoute, error) {
<-ctx.Done()
return nil, ctx.Err()
}
func (slowDB) GetRandomPrefixesByLengthContext(ctx context.Context, _, _, _ int) ([]database.LiveRoute, error) {
<-ctx.Done()
return nil, ctx.Err()
}
// TestRoutesAnswer504WhenDatabaseReadIsCutOff sends a request through the
// server's router for each route that reads the database, with a store whose
// reads fail when the request's time limit passes. Each route answers 504, the
// JSON routes with the JSON error body. Before the fix they answered 408 or,
// when the handler wrote first, its own error: 500, or 404 on the IP lookups.
// 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{})
tests := []struct {
path string
wantJSONBody bool
}{
{path: "/as/64500"},
{path: "/prefix/192.0.2.0/24"},
{path: "/prefixlength/24"},
{path: "/prefixlength6/48"},
{path: "/status.json", wantJSONBody: true},
{path: "/ip/192.0.2.1", wantJSONBody: true},
{path: "/api/v1/stats", wantJSONBody: true},
{path: "/api/v1/ip/192.0.2.1", wantJSONBody: true},
{path: "/api/v1/as/64500", wantJSONBody: true},
{path: "/api/v1/prefix/192.0.2.0/24", wantJSONBody: true},
}
for _, tt := range tests {
ctx, cancel := context.WithTimeout(context.Background(), shortRequestDeadline)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/prefix/192.0.2.0/24", nil)) s.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil).WithContext(ctx))
if rec.Code != http.StatusOK { cancel()
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
if rec.Code != http.StatusGatewayTimeout {
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)
}
}
} }
page := rec.Body.String() // checkJSONErrorCode checks that rec holds the JSON error body with the given
for _, asn := range origins { // code.
info, ok := asinfo.Get(asn) func checkJSONErrorCode(t *testing.T, name string, rec *httptest.ResponseRecorder, want int) {
if !ok { t.Helper()
t.Fatalf("AS%d is not in the AS info data", asn)
var body struct {
Status string `json:"status"`
Error struct {
Code int `json:"code"`
} `json:"error"`
} }
if want := "(" + html.EscapeString(info.Handle) + ")"; !strings.Contains(page, want) { if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Errorf("page does not show AS%d's handle %q", asn, want) t.Errorf("%s: decoding the answer %q: %v", name, rec.Body, err)
}
if want := html.EscapeString(info.Description); !strings.Contains(page, want) { return
t.Errorf("page does not show AS%d's description %q", asn, want)
} }
if body.Status != "error" || body.Error.Code != want {
t.Errorf("%s: answer %s, want status \"error\" and error code %d", name, rec.Body, want)
} }
} }
+17 -131
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -109,142 +110,24 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
}) })
} }
// timeoutWriter wraps ResponseWriter to prevent concurrent writes after timeout // requestTimeoutMessage is the error message sent with 504 Gateway Timeout.
type timeoutWriter struct { const requestTimeoutMessage = "Request timeout"
http.ResponseWriter
mu sync.Mutex
written bool
header http.Header // cached header to prevent concurrent access
}
func (tw *timeoutWriter) Write(b []byte) (int, error) { // deadlinePassed reports whether ctx ended because its deadline passed. When
tw.mu.Lock() // a handler's database read or other lookup fails, it uses this to tell a
defer tw.mu.Unlock() // request cut off by its time limit, which answers 504, from other failures.
func deadlinePassed(ctx context.Context) bool {
if tw.written { return errors.Is(ctx.Err(), context.DeadlineExceeded)
return 0, nil // Discard writes after timeout
}
return tw.ResponseWriter.Write(b)
}
func (tw *timeoutWriter) WriteHeader(statusCode int) {
tw.mu.Lock()
defer tw.mu.Unlock()
if tw.written {
return // Discard writes after timeout
}
tw.ResponseWriter.WriteHeader(statusCode)
}
func (tw *timeoutWriter) Header() http.Header {
tw.mu.Lock()
defer tw.mu.Unlock()
if tw.written {
// Return a copy to prevent modifications after timeout
if tw.header == nil {
tw.header = make(http.Header)
}
return tw.header
}
return tw.ResponseWriter.Header()
}
func (tw *timeoutWriter) markWritten() {
tw.mu.Lock()
defer tw.mu.Unlock()
tw.written = true
}
// TimeoutMiddleware creates an HTTP middleware that enforces a request timeout.
// If the handler does not complete within the specified duration, the middleware
// returns a JSON error response with HTTP status 408 (Request Timeout).
//
// The timeout parameter specifies the maximum duration allowed for request processing.
// The returned middleware handles panics from the wrapped handler by re-panicking
// 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 {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
tw := &timeoutWriter{
ResponseWriter: w,
header: make(http.Header),
}
done := make(chan struct{})
panicChan := make(chan interface{}, 1)
go func() {
defer func() {
if p := recover(); p != nil {
panicChan <- p
}
}()
next.ServeHTTP(tw, r.WithContext(ctx))
close(done)
}()
select {
case p := <-panicChan:
panic(p)
case <-done:
return
case <-ctx.Done():
tw.markWritten() // Prevent the handler from writing after timeout
execTime := time.Since(startTime)
// Log the timeout as a warning
logger.Warn("Request timeout",
"method", r.Method,
"path", r.URL.Path,
"duration_ms", execTime.Milliseconds(),
"remote_addr", r.RemoteAddr,
)
// Write directly to the underlying writer since we've marked tw as written
// This is safe because markWritten() prevents the handler from writing
tw.mu.Lock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusRequestTimeout)
tw.mu.Unlock()
response := map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"msg": "Request timeout",
"code": http.StatusRequestTimeout,
},
"@meta": map[string]interface{}{
"time_zone": "UTC",
"api_version": 1,
"execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()),
},
}
_ = json.NewEncoder(w).Encode(response)
}
})
}
} }
// JSONValidationMiddleware is an HTTP middleware that validates JSON API responses. // JSONValidationMiddleware is an HTTP middleware that validates JSON API responses.
// It ensures that responses with Content-Type "application/json" contain valid JSON. // It ensures that responses with Content-Type "application/json" contain valid JSON.
// //
// If a response is not valid JSON or is empty when JSON is expected, the middleware // If a response is not valid JSON or is empty when JSON is expected, the middleware
// returns a properly formatted JSON error response. For timeout errors (status 408), // returns a properly formatted JSON error response. If the request's time limit
// the error message will be "Request timeout". For other errors, it returns // has passed, it answers 504 with the error message "Request timeout". For other
// "Internal server error" with status 500 if the original status was 200. // errors, it returns "Internal server error" with status 500 if the original
// status was 200.
// //
// Non-JSON responses are passed through unchanged. // Non-JSON responses are passed through unchanged.
func JSONValidationMiddleware(next http.Handler) http.Handler { func JSONValidationMiddleware(next http.Handler) http.Handler {
@@ -293,11 +176,14 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
if statusCode == http.StatusOK { if statusCode == http.StatusOK {
statusCode = http.StatusInternalServerError statusCode = http.StatusInternalServerError
} }
if deadlinePassed(r.Context()) {
statusCode = http.StatusGatewayTimeout
}
w.WriteHeader(statusCode) w.WriteHeader(statusCode)
errorMsg := "Internal server error" errorMsg := "Internal server error"
if statusCode == http.StatusRequestTimeout { if statusCode == http.StatusGatewayTimeout {
errorMsg = "Request timeout" errorMsg = requestTimeoutMessage
} }
response := map[string]interface{}{ response := map[string]interface{}{
+5 -2
View File
@@ -16,9 +16,12 @@ 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
r.Use(TimeoutMiddleware(requestTimeout, s.logger.Logger))
r.Use(JSONResponseMiddleware) r.Use(JSONResponseMiddleware)
// Timeout must come after JSONResponseMiddleware, which sends its answer
// when the handler returns and would send a 200 ahead of Timeout's 504 for
// a handler that wrote nothing.
const requestTimeout = 30 * time.Second // Increased from 8s for slow queries
r.Use(middleware.Timeout(requestTimeout))
// Routes // Routes
r.Get("/", s.handleIndex()) r.Get("/", s.handleIndex())
+4 -14
View File
@@ -3,8 +3,6 @@ package server
import ( import (
"context" "context"
"fmt"
"net"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -55,12 +53,9 @@ func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger,
return s return s
} }
// Start listens on the configured port and serves HTTP in the background. // Start starts the HTTP server
// It returns an error if it cannot listen, for example because the port is
// already in use.
func (s *Server) Start() error { func (s *Server) Start() error {
const ( const (
network = "tcp"
readHeaderTimeout = 40 * time.Second readHeaderTimeout = 40 * time.Second
readTimeout = 60 * time.Second readTimeout = 60 * time.Second
writeTimeout = 60 * time.Second writeTimeout = 60 * time.Second
@@ -78,15 +73,10 @@ func (s *Server) Start() error {
s.logger.Info("Starting HTTP server", "port", s.port, "addr", s.srv.Addr) s.logger.Info("Starting HTTP server", "port", s.port, "addr", s.srv.Addr)
listener, err := net.Listen(network, s.srv.Addr) // Start in goroutine but log when actually listening
if err != nil {
return fmt.Errorf("starting HTTP server: %w", err)
}
s.logger.Info("HTTP server listening", "addr", listener.Addr().String())
go func() { go func() {
if err := s.srv.Serve(listener); err != nil && err != http.ErrServerClosed { s.logger.Info("HTTP server listening", "addr", s.srv.Addr)
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
s.logger.Error("HTTP server error", "error", err) s.logger.Error("HTTP server error", "error", err)
} }
}() }()
-33
View File
@@ -1,33 +0,0 @@
package server
import (
"context"
"net"
"testing"
"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/streamer"
)
// TestStartFailsWhenPortIsHeld holds a port with a listener of its own and
// checks that Start on that port returns an error. Before the fix Start
// 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")
if err != nil {
t.Fatalf("failed to open the test listener: %v", err)
}
defer func() { _ = held.Close() }()
port := held.Addr().(*net.TCPAddr).Port
log := logger.New()
s := 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)
}
}
+13 -35
View File
@@ -7,7 +7,6 @@ import (
"compress/gzip" "compress/gzip"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"math" "math"
@@ -49,7 +48,6 @@ const (
metricsUpdateRate = time.Second metricsUpdateRate = time.Second
minBackoffDelay = 5 * time.Second minBackoffDelay = 5 * time.Second
maxBackoffDelay = 320 * time.Second maxBackoffDelay = 320 * time.Second
streamIdleTimeout = 60 * time.Second // Reconnect when no line arrives for this long
metricsLogInterval = 10 * time.Second metricsLogInterval = 10 * time.Second
bytesPerKB = 1024 bytesPerKB = 1024
bytesPerMB = 1024 * 1024 bytesPerMB = 1024 * 1024
@@ -60,9 +58,6 @@ const (
backpressureSlope = 2.0 // Slope for linear drop probability increase backpressureSlope = 2.0 // Slope for linear drop probability increase
) )
// errStreamEnded is returned by stream when RIS Live ends the response.
var errStreamEnded = errors.New("RIS Live ended the stream")
// MessageHandler defines the interface for processing RIS messages. // MessageHandler defines the interface for processing RIS messages.
// Implementations must specify which message types they want to receive, // Implementations must specify which message types they want to receive,
// how to process messages, and their desired queue capacity. // how to process messages, and their desired queue capacity.
@@ -112,8 +107,6 @@ type Streamer struct {
logger *logger.Logger logger *logger.Logger
client *http.Client client *http.Client
url string url string
idleTimeout time.Duration // Drop the connection when no line arrives for this long
minBackoff time.Duration // First wait before reconnecting; doubles up to maxBackoffDelay
handlers []*handlerInfo handlers []*handlerInfo
rawHandler RawMessageHandler rawHandler RawMessageHandler
mu sync.RWMutex mu sync.RWMutex
@@ -133,8 +126,6 @@ func New(logger *logger.Logger, metrics *metrics.Tracker) *Streamer {
return &Streamer{ return &Streamer{
logger: logger, logger: logger,
url: risLiveURL, url: risLiveURL,
idleTimeout: streamIdleTimeout,
minBackoff: minBackoffDelay,
client: &http.Client{ client: &http.Client{
Timeout: 0, // No timeout for streaming Timeout: 0, // No timeout for streaming
Transport: &http.Transport{ Transport: &http.Transport{
@@ -414,7 +405,7 @@ func (s *Streamer) updateMetrics(messageBytes int) {
// streamWithReconnect handles streaming with automatic reconnection and exponential backoff // streamWithReconnect handles streaming with automatic reconnection and exponential backoff
func (s *Streamer) streamWithReconnect(ctx context.Context) { func (s *Streamer) streamWithReconnect(ctx context.Context) {
backoffDelay := s.minBackoff backoffDelay := minBackoffDelay
consecutiveFailures := 0 consecutiveFailures := 0
for { for {
@@ -431,9 +422,8 @@ func (s *Streamer) streamWithReconnect(ctx context.Context) {
err := s.stream(ctx) err := s.stream(ctx)
streamDuration := time.Since(startTime) streamDuration := time.Since(startTime)
// Only Stop ends the streamer. A stream that ends any other way, if err == nil {
// with or without an error, is a disconnect and is reconnected. // Clean exit (context cancelled)
if ctx.Err() != nil {
return return
} }
@@ -444,13 +434,17 @@ func (s *Streamer) streamWithReconnect(ctx context.Context) {
"stream_duration", streamDuration) "stream_duration", streamDuration)
s.metrics.SetConnected(false) s.metrics.SetConnected(false)
// A stream that lasted more than 30 seconds resets the backoff. That // Check if context is cancelled
// includes a stream dropped after streamIdleTimeout with no line, so if ctx.Err() != nil {
// a silent feed is retried after the first backoff delay every time. return
}
// If we streamed for more than 30 seconds, reset the backoff
// This indicates we had a successful connection that received data
if streamDuration > 30*time.Second { if streamDuration > 30*time.Second {
s.logger.Info("Resetting backoff delay due to successful connection", s.logger.Info("Resetting backoff delay due to successful connection",
"stream_duration", streamDuration) "stream_duration", streamDuration)
backoffDelay = s.minBackoff backoffDelay = minBackoffDelay
consecutiveFailures = 0 consecutiveFailures = 0
} else { } else {
// Increment consecutive failures // Increment consecutive failures
@@ -483,13 +477,7 @@ func (s *Streamer) stream(ctx context.Context) error {
connCtx, connCancel := context.WithCancel(ctx) connCtx, connCancel := context.WithCancel(ctx)
defer connCancel() defer connCancel()
// The idle timer drops the connection, by cancelling connCtx, when no req, err := http.NewRequestWithContext(ctx, "GET", s.url, nil)
// line arrives for s.idleTimeout. Each line read starts it over. It
// also covers the wait for the server to answer.
idleTimer := time.AfterFunc(s.idleTimeout, connCancel)
defer idleTimer.Stop()
req, err := http.NewRequestWithContext(connCtx, "GET", s.url, nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to create request: %w", err)
} }
@@ -571,8 +559,6 @@ func (s *Streamer) stream(ctx context.Context) error {
scanner := bufio.NewScanner(reader) scanner := bufio.NewScanner(reader)
for scanner.Scan() { for scanner.Scan() {
idleTimer.Reset(s.idleTimeout)
select { select {
case <-ctx.Done(): case <-ctx.Done():
s.logger.Info("Stream stopped by context") s.logger.Info("Stream stopped by context")
@@ -721,19 +707,11 @@ func (s *Streamer) stream(ctx context.Context) error {
s.mu.RUnlock() s.mu.RUnlock()
} }
if ctx.Err() != nil {
return ctx.Err()
}
if connCtx.Err() != nil {
return fmt.Errorf("no line received for %s", s.idleTimeout)
}
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return fmt.Errorf("scanner error: %w", err) return fmt.Errorf("scanner error: %w", err)
} }
return errStreamEnded return nil
} }
// shouldDropForBackpressure determines if a message should be dropped based on queue utilization // shouldDropForBackpressure determines if a message should be dropped based on queue utilization
+10 -142
View File
@@ -7,7 +7,6 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"runtime" "runtime"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -40,14 +39,6 @@ func TestNewStreamer(t *testing.T) {
if s.metrics != metricsTracker { if s.metrics != metricsTracker {
t.Error("metrics tracker not set correctly") t.Error("metrics tracker not set correctly")
} }
if s.idleTimeout != streamIdleTimeout {
t.Errorf("idleTimeout = %s, want %s", s.idleTimeout, streamIdleTimeout)
}
if s.minBackoff != minBackoffDelay {
t.Errorf("minBackoff = %s, want %s", s.minBackoff, minBackoffDelay)
}
} }
// TestStreamDoesNotLeakTickersAcrossReconnects drives many short-lived // TestStreamDoesNotLeakTickersAcrossReconnects drives many short-lived
@@ -66,8 +57,8 @@ func TestStreamDoesNotLeakTickersAcrossReconnects(t *testing.T) {
// One warm-up connection so any persistent HTTP transport goroutine exists // One warm-up connection so any persistent HTTP transport goroutine exists
// before we take the baseline. // before we take the baseline.
if err := s.stream(context.Background()); !errors.Is(err, errStreamEnded) { if err := s.stream(context.Background()); err != nil {
t.Fatalf("warm-up stream returned %v, want %v", err, errStreamEnded) t.Fatalf("warm-up stream returned error: %v", err)
} }
s.client.CloseIdleConnections() s.client.CloseIdleConnections()
@@ -75,8 +66,8 @@ func TestStreamDoesNotLeakTickersAcrossReconnects(t *testing.T) {
const reconnects = 20 const reconnects = 20
for range reconnects { for range reconnects {
if err := s.stream(context.Background()); !errors.Is(err, errStreamEnded) { if err := s.stream(context.Background()); err != nil {
t.Fatalf("stream returned %v, want %v", err, errStreamEnded) t.Fatalf("stream returned error: %v", err)
} }
} }
s.client.CloseIdleConnections() s.client.CloseIdleConnections()
@@ -87,35 +78,23 @@ func TestStreamDoesNotLeakTickersAcrossReconnects(t *testing.T) {
} }
} }
// updateLine is one UPDATE message as RIS Live sends it. // updateHandler wants UPDATE messages and does nothing with them.
const updateLine = `{"type":"ris_message","data":{"type":"UPDATE","peer":"192.0.2.1",` + type updateHandler struct{}
`"peer_asn":"64496","timestamp":1700000000}}` + "\n"
// updateHandler wants UPDATE messages. For each one it handles it sends on
// handled if there is room, and otherwise does nothing; a nil handled is
// never sent on.
type updateHandler struct {
handled chan struct{}
}
func (updateHandler) WantsMessage(messageType string) bool { return messageType == "UPDATE" } func (updateHandler) WantsMessage(messageType string) bool { return messageType == "UPDATE" }
func (updateHandler) HandleMessage(*ristypes.RISMessage) {}
func (updateHandler) QueueCapacity() int { return 10 } func (updateHandler) QueueCapacity() int { return 10 }
func (h updateHandler) HandleMessage(*ristypes.RISMessage) {
select {
case h.handled <- struct{}{}:
default:
}
}
// TestStopBeforeMessageReachesQueues stops the streamer after the read loop // TestStopBeforeMessageReachesQueues stops the streamer after the read loop
// has checked for cancellation but before it hands the message to the handler // has checked for cancellation but before it hands the message to the handler
// queues. That is the gap a stop from another goroutine can land in, and it // queues. That is the gap a stop from another goroutine can land in, and it
// used to end in "send on closed channel". The raw handler runs in that gap on // used to end in "send on closed channel". The raw handler runs in that gap on
// the read loop itself, so calling Stop from it hits the gap every time. // the read loop itself, so calling Stop from it hits the gap every time.
func TestStopBeforeMessageReachesQueues(t *testing.T) { func TestStopBeforeMessageReachesQueues(t *testing.T) {
const line = `{"type":"ris_message","data":{"type":"UPDATE","peer":"192.0.2.1",` +
`"peer_asn":"64496","timestamp":1700000000}}` + "\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, updateLine) _, _ = io.WriteString(w, line)
})) }))
defer srv.Close() defer srv.Close()
@@ -138,117 +117,6 @@ func TestStopBeforeMessageReachesQueues(t *testing.T) {
s.Stop() s.Stop()
} }
// TestReconnectWhenStreamEnds has the server end the first response
// normally, as when RIS Live restarts. The streamer must treat that as a
// disconnect and connect again.
func TestReconnectWhenStreamEnds(t *testing.T) {
endResponse := func(http.ResponseWriter, *http.Request) {}
checkReconnect(t, endResponse, streamIdleTimeout)
}
// TestReconnectWhenStreamGoesIdle has the server hold the first response
// open without sending anything. The streamer must drop it once the idle
// timeout passes and connect again.
func TestReconnectWhenStreamGoesIdle(t *testing.T) {
const idleTimeout = 50 * time.Millisecond
holdResponse := func(w http.ResponseWriter, r *http.Request) {
w.(http.Flusher).Flush()
<-r.Context().Done()
}
checkReconnect(t, holdResponse, idleTimeout)
}
// TestReconnectWhenServerNeverAnswers has the server accept the first
// request and never answer it, not even with headers. The streamer must give
// up on it once the idle timeout passes and connect again.
func TestReconnectWhenServerNeverAnswers(t *testing.T) {
const idleTimeout = 50 * time.Millisecond
neverAnswer := func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}
checkReconnect(t, neverAnswer, idleTimeout)
}
// TestStreamNotDroppedWhileLinesArrive has the server send lines well within
// the idle timeout for several times the timeout, then end the response. Each
// line must start the idle timeout over, so the stream lasts until the server
// ends it instead of being dropped as idle.
func TestStreamNotDroppedWhileLinesArrive(t *testing.T) {
const (
idleTimeout = 100 * time.Millisecond
lineInterval = idleTimeout / 10
sendFor = 5 * idleTimeout
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
deadline := time.Now().Add(sendFor)
for time.Now().Before(deadline) {
_, _ = io.WriteString(w, updateLine)
w.(http.Flusher).Flush()
time.Sleep(lineInterval)
}
}))
defer srv.Close()
s := New(logger.New(), metrics.New())
s.url = srv.URL
s.idleTimeout = idleTimeout
if err := s.stream(context.Background()); !errors.Is(err, errStreamEnded) {
t.Fatalf("stream returned %v, want %v", err, errStreamEnded)
}
}
// checkReconnect runs a streamer against a fake RIS Live server. The first
// response is served by firstResponse; every later one carries one UPDATE
// line and then stays open. The streamer must come back for a second
// response, must not be marked connected when it does, and must handle the
// UPDATE in it.
func checkReconnect(t *testing.T, firstResponse http.HandlerFunc, idleTimeout time.Duration) {
t.Helper()
const maxWait = 5 * time.Second
tracker := metrics.New()
connectedAtReconnect := make(chan bool, 1)
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requests.Add(1) == 1 {
firstResponse(w, r)
return
}
select {
case connectedAtReconnect <- tracker.IsConnected():
default:
}
_, _ = io.WriteString(w, updateLine)
w.(http.Flusher).Flush()
<-r.Context().Done()
}))
defer srv.Close()
s := New(logger.New(), tracker)
s.url = srv.URL
s.idleTimeout = idleTimeout
s.minBackoff = time.Millisecond
handler := updateHandler{handled: make(chan struct{}, 1)}
s.RegisterHandler(handler)
if err := s.Start(); err != nil {
t.Fatalf("Start returned error: %v", err)
}
defer s.Stop()
select {
case <-handler.handled:
case <-time.After(maxWait):
t.Fatalf("no message from a second response handled within %s", maxWait)
}
if <-connectedAtReconnect {
t.Error("streamer was still marked connected when it came back for a second response")
}
}
// settledGoroutineCount lets transient goroutines finish, then reports the // settledGoroutineCount lets transient goroutines finish, then reports the
// current count. // current count.
func settledGoroutineCount() int { func settledGoroutineCount() int {
-1
View File
@@ -283,7 +283,6 @@
<div class="origin-item"> <div class="origin-item">
<a href="/as/{{.ASN}}" class="as-link">AS{{.ASN}}</a> <a href="/as/{{.ASN}}" class="as-link">AS{{.ASN}}</a>
{{if .Handle}} ({{.Handle}}){{end}} {{if .Handle}} ({{.Handle}}){{end}}
{{if .Description}} {{.Description}}{{end}}
<span style="color: #7f8c8d; margin-left: 10px;">{{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}}</span> <span style="color: #7f8c8d; margin-left: 10px;">{{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}}</span>
</div> </div>
{{end}} {{end}}