Author SHA1 Message Date
sneak fb8ffe9b97 Show the prefix page's origin names from the prefix query (closes #67)
check / check (push) Waiting to run
The prefix page looked up each origin AS's details only for its handle and
description, and that lookup also reads every live route the AS originates.
GetPrefixDetailsContext already selects the handle and description; it now
returns them keyed by AS number, and the page uses them instead.

The origin list was handed each description but never rendered it; it now
shows it after the handle.

Model: opus-5-5
2026-10-14 15:28:24 +00:00
9 changed files with 232 additions and 265 deletions
+14 -7
View File
@@ -1632,16 +1632,17 @@ func (d *Database) GetASPeersContext(ctx context.Context, asn int) ([]ASPeer, er
}
// GetPrefixDetails returns detailed information about a prefix
func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, error) {
func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, map[int]ASN, error) {
return d.GetPrefixDetailsContext(context.Background(), prefix)
}
// GetPrefixDetailsContext returns detailed information about a prefix with context support
func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, error) {
// GetPrefixDetailsContext returns the live routes for a prefix and, keyed by
// AS number, the handle and description of each AS that originates it.
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
_, ipnet, err := net.ParseCIDR(prefix)
if err != nil {
return nil, fmt.Errorf("invalid prefix format: %w", err)
return nil, nil, fmt.Errorf("invalid prefix format: %w", err)
}
tableName := "live_routes_v4"
@@ -1663,11 +1664,12 @@ func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) (
rows, err := d.db.QueryContext(ctx, query, prefix)
if err != nil {
return nil, fmt.Errorf("failed to query prefix details: %w", err)
return nil, nil, fmt.Errorf("failed to query prefix details: %w", err)
}
defer func() { _ = rows.Close() }()
var routes []LiveRoute
origins := make(map[int]ASN)
for rows.Next() {
var route LiveRoute
var pathJSON string
@@ -1690,13 +1692,18 @@ func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) (
route.IPVersion = ipVersion
route.MaskLength, _ = ipnet.Mask.Size()
routes = append(routes, route)
origins[route.OriginASN] = ASN{
ASN: route.OriginASN,
Handle: handle.String,
Description: description.String,
}
}
if len(routes) == 0 {
return nil, fmt.Errorf("%w: %s", ErrNoRoute, prefix)
return nil, nil, fmt.Errorf("%w: %s", ErrNoRoute, prefix)
}
return routes, nil
return routes, origins, nil
}
// 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)
GetASPeers(asn int) ([]ASPeer, error)
GetASPeersContext(ctx context.Context, asn int) ([]ASPeer, error)
GetPrefixDetails(prefix string) ([]LiveRoute, error)
GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, error)
GetPrefixDetails(prefix string) ([]LiveRoute, map[int]ASN, error)
GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, map[int]ASN, error)
GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error)
GetRandomPrefixesByLengthContext(ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error)
+5 -3
View File
@@ -252,13 +252,15 @@ func (m *mockStore) GetASDetailsContext(ctx context.Context, asn int) (*database
}
// GetPrefixDetails mock implementation
func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, error) {
func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, map[int]database.ASN, error) {
// Return empty routes for now
return []database.LiveRoute{}, nil
return []database.LiveRoute{}, map[int]database.ASN{}, nil
}
// GetPrefixDetailsContext mock implementation with context support
func (m *mockStore) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]database.LiveRoute, error) {
func (m *mockStore) GetPrefixDetailsContext(
ctx context.Context, prefix string,
) ([]database.LiveRoute, map[int]database.ASN, error) {
return m.GetPrefixDetails(prefix)
}
+1 -1
View File
@@ -170,7 +170,7 @@ func TestPrefixHandlerAppliesEachPeersLastUpdateInABatch(t *testing.T) {
}
ctx := context.Background()
routes, err := db.GetPrefixDetailsContext(ctx, prefix)
routes, _, err := db.GetPrefixDetailsContext(ctx, prefix)
if err != nil && !errors.Is(err, database.ErrNoRoute) {
t.Fatalf("GetPrefixDetailsContext: %v", err)
}
+21 -72
View File
@@ -201,16 +201,12 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
select {
case <-ctx.Done():
s.logger.Error("Database stats timeout in status.json")
writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
writeJSONError(w, http.StatusRequestTimeout, "Database timeout")
return
case err := <-errChan:
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
case dbStats = <-statsChan:
@@ -390,7 +386,7 @@ func (s *Server) handleStats() http.HandlerFunc {
// Check if context is already cancelled
select {
case <-ctx.Done():
writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
http.Error(w, "Request timeout", http.StatusRequestTimeout)
return
default:
@@ -419,16 +415,11 @@ func (s *Server) handleStats() http.HandlerFunc {
select {
case <-ctx.Done():
s.logger.Error("Database stats timeout")
writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
// Don't write response here - timeout middleware already handles it
return
case err := <-errChan:
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
case dbStats = <-statsChan:
@@ -631,13 +622,6 @@ 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 {
writeJSONError(w, http.StatusBadRequest, "No valid IPs or hostnames provided")
@@ -680,13 +664,6 @@ 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)
if len(response.Results) == 0 && len(response.Errors) > 0 {
writeJSONError(w, http.StatusNotFound, "No routes found: "+response.Errors[0])
@@ -726,12 +703,9 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn)
if err != nil {
switch {
case errors.Is(err, database.ErrNoRoute):
if errors.Is(err, database.ErrNoRoute) {
writeJSONError(w, http.StatusNotFound, err.Error())
case deadlinePassed(r.Context()):
writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
default:
} else {
writeJSONError(w, http.StatusInternalServerError, err.Error())
}
@@ -785,14 +759,11 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
}
prefix := parsed.Masked().String()
routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
routes, _, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil {
switch {
case errors.Is(err, database.ErrNoRoute):
if errors.Is(err, database.ErrNoRoute) {
writeJSONError(w, http.StatusNotFound, err.Error())
case deadlinePassed(r.Context()):
writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
default:
} else {
writeJSONError(w, http.StatusInternalServerError, err.Error())
}
@@ -832,12 +803,9 @@ func (s *Server) handleASDetail() http.HandlerFunc {
asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn)
if err != nil {
switch {
case errors.Is(err, database.ErrNoRoute):
if errors.Is(err, database.ErrNoRoute) {
http.Error(w, "AS not found", http.StatusNotFound)
case deadlinePassed(r.Context()):
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
default:
} else {
s.logger.Error("Failed to get AS details", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
@@ -962,14 +930,11 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
}
prefix := parsed.Masked().String()
routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
routes, originASNs, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil {
switch {
case errors.Is(err, database.ErrNoRoute):
if errors.Is(err, database.ErrNoRoute) {
http.Error(w, "Prefix not found", http.StatusNotFound)
case deadlinePassed(r.Context()):
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
default:
} else {
s.logger.Error("Failed to get prefix details", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
@@ -987,18 +952,10 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
originMap := make(map[int]*ASNInfo)
for _, route := range routes {
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{
ASN: route.OriginASN,
Handle: handle,
Description: description,
Handle: originASNs[route.OriginASN].Handle,
Description: originASNs[route.OriginASN].Description,
PeerCount: 0,
}
}
@@ -1123,12 +1080,8 @@ func (s *Server) handlePrefixLength() http.HandlerFunc {
const maxPrefixes = 500
prefixes, err := s.db.GetRandomPrefixesByLengthContext(r.Context(), maskLength, ipVersion, maxPrefixes)
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)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
s.logger.Error("Failed to get prefixes by length", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
@@ -1218,12 +1171,8 @@ func (s *Server) handlePrefixLength6() http.HandlerFunc {
const maxPrefixes = 500
prefixes, err := s.db.GetRandomPrefixesByLengthContext(r.Context(), maskLength, ipVersion, maxPrefixes)
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)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
s.logger.Error("Failed to get prefixes by length", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
+55 -158
View File
@@ -3,10 +3,13 @@ package server
import (
"context"
"encoding/json"
"errors"
"html"
"net/http"
"net/http/httptest"
"runtime"
"slices"
"strings"
"testing"
"time"
@@ -15,6 +18,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
"github.com/google/uuid"
)
@@ -200,176 +204,69 @@ func TestPrefixDetailHandlersParsePrefix(t *testing.T) {
}
}
// shortRequestDeadline is the deadline the tests below give a request. The
// 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 {
// failingASDetailsDB wraps a real store and fails every AS details lookup.
type failingASDetailsDB struct {
database.Store
}
func (slowDB) GetStatsContext(ctx context.Context) (database.Stats, error) {
<-ctx.Done()
return database.Stats{}, ctx.Err()
func (failingASDetailsDB) GetASDetailsContext(_ context.Context, _ int) (*database.ASN, []database.LiveRoute, error) {
return nil, nil, errors.New("AS details lookup failed")
}
func (slowDB) GetIPInfoContext(ctx context.Context, _ string) (*database.IPInfo, error) {
<-ctx.Done()
return nil, ctx.Err()
}
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},
// TestPrefixDetailShowsOriginNames checks that the prefix page shows each
// origin AS's handle and description without looking up the AS's details,
// 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() })
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)
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 {
t.Fatalf("GetOrCreateASN(%d): %v", asn, err)
}
}
}
// 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) {
t.Helper()
var body struct {
Status string `json:"status"`
Error struct {
Code int `json:"code"`
} `json:"error"`
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)
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Errorf("%s: decoding the answer %q: %v", name, rec.Body, err)
return
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))
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
}
if body.Status != "error" || body.Error.Code != want {
t.Errorf("%s: answer %s, want status \"error\" and error code %d", name, rec.Body, want)
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) {
t.Errorf("page does not show AS%d's handle %q", asn, want)
}
if want := html.EscapeString(info.Description); !strings.Contains(page, want) {
t.Errorf("page does not show AS%d's description %q", asn, want)
}
}
}
+131 -17
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
@@ -110,24 +109,142 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
})
}
// requestTimeoutMessage is the error message sent with 504 Gateway Timeout.
const requestTimeoutMessage = "Request timeout"
// timeoutWriter wraps ResponseWriter to prevent concurrent writes after timeout
type timeoutWriter struct {
http.ResponseWriter
mu sync.Mutex
written bool
header http.Header // cached header to prevent concurrent access
}
// deadlinePassed reports whether ctx ended because its deadline passed. When
// a handler's database read or other lookup fails, it uses this to tell a
// request cut off by its time limit, which answers 504, from other failures.
func deadlinePassed(ctx context.Context) bool {
return errors.Is(ctx.Err(), context.DeadlineExceeded)
func (tw *timeoutWriter) Write(b []byte) (int, error) {
tw.mu.Lock()
defer tw.mu.Unlock()
if tw.written {
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.
// 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
// returns a properly formatted JSON error response. If the request's time limit
// has passed, it answers 504 with the error message "Request timeout". For other
// errors, it returns "Internal server error" with status 500 if the original
// status was 200.
// returns a properly formatted JSON error response. For timeout errors (status 408),
// the error message will be "Request timeout". For other errors, it returns
// "Internal server error" with status 500 if the original status was 200.
//
// Non-JSON responses are passed through unchanged.
func JSONValidationMiddleware(next http.Handler) http.Handler {
@@ -176,14 +293,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"
if statusCode == http.StatusGatewayTimeout {
errorMsg = requestTimeoutMessage
if statusCode == http.StatusRequestTimeout {
errorMsg = "Request timeout"
}
response := map[string]interface{}{
+2 -5
View File
@@ -16,12 +16,9 @@ func (s *Server) setupRoutes() {
r.Use(middleware.RealIP)
r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging
r.Use(middleware.Recoverer)
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))
r.Use(TimeoutMiddleware(requestTimeout, s.logger.Logger))
r.Use(JSONResponseMiddleware)
// Routes
r.Get("/", s.handleIndex())
+1
View File
@@ -283,6 +283,7 @@
<div class="origin-item">
<a href="/as/{{.ASN}}" class="as-link">AS{{.ASN}}</a>
{{if .Handle}} ({{.Handle}}){{end}}
{{if .Description}} {{.Description}}{{end}}
<span style="color: #7f8c8d; margin-left: 10px;">{{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}}</span>
</div>
{{end}}