Files
MrAlders0n 080141145e Read-only scope lists, 503 on statement timeouts, log read-model restores (#264)
Read-only servers now restore the saved MeshMapper scope catalogues (a read-only
step), so region scope lists are no longer empty. A statement that hits the
pool's statement_timeout answers 503 with Retry-After on route evidence and the
read-model pages instead of 500. Read-model trust logs when it comes back after
a drop, and when repair runs drop it.
2026-10-21 11:28:55 -04:00

121 lines
4.7 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
func routeEvidenceQuery(r *http.Request, iata, key string, now time.Time) (api.RouteEvidenceQuery, error) {
q := api.RouteEvidenceQuery{}
values := r.URL.Query()
for _, name := range []string{"pageCursor", "since", "until", "range", "limit"} {
if len(values[name]) > 1 {
return q, api.ErrRouteEvidenceInput
}
}
if values.Has("cursor") {
return q, api.ErrRouteEvidenceInput
}
limit, err := parseLimit(r, 50)
if err != nil {
return q, err
}
q.Limit = limit
if values.Has("pageCursor") {
c, err := api.ParseRouteEvidenceCursor(values.Get("pageCursor"))
if err != nil || c.IATA != iata || c.PathKey != key {
return q, api.ErrRouteEvidenceInput
}
q.Cursor = c
q.Since = c.Since
q.Until = c.Until
} else {
q.Until = now.UTC().Truncate(time.Millisecond)
q.Since = q.Until.Add(-24 * time.Hour)
}
if values.Has("range") {
if values.Has("pageCursor") || values.Has("since") || values.Has("until") {
return q, api.ErrRouteEvidenceInput
}
duration, err := time.ParseDuration(values.Get("range"))
if err != nil || duration < time.Millisecond || duration > api.MaxRouteEvidenceWindow || duration%time.Millisecond != 0 {
return q, api.ErrRouteEvidenceInput
}
q.Since = q.Until.Add(-duration)
}
if values.Has("since") || values.Has("until") {
if !values.Has("since") || !values.Has("until") {
return q, api.ErrRouteEvidenceInput
}
since, e1 := strconv.ParseInt(values.Get("since"), 10, 64)
until, e2 := strconv.ParseInt(values.Get("until"), 10, 64)
if e1 != nil || e2 != nil || since < 0 || until < 0 || since > 253402300799999 || until > 253402300799999 {
return q, api.ErrRouteEvidenceInput
}
q.Since = time.UnixMilli(since).UTC()
q.Until = time.UnixMilli(until).UTC()
if q.Cursor != nil && (!q.Since.Equal(q.Cursor.Since) || !q.Until.Equal(q.Cursor.Until)) {
return q, api.ErrRouteEvidenceInput
}
}
if !api.ValidRouteEvidenceWindow(q.Since, q.Until) || q.Until.After(now.Add(clockSkewTolerance)) {
return q, api.ErrRouteEvidenceInput
}
return q, nil
}
// getRouteEvidence godoc
//
// @Summary Get retained reports matching a full saved route prefix sequence
// @Description Matches the saved IATA, complete path bytes and hash width. Excludes TRACE and unclassified reports; other widths and search-result subsegments are not included. Byte matches do not prove hop identities or delivery. The route and its historical counter can outlive raw evidence. Cursors retain full timestamp precision and pin route/window scope.
// @Tags Routes
// @Produce json
// @Param iata path string true "Three-character IATA code"
// @Param pathKey path string true "Stable 32-hex pathKey from a known-route response"
// @Param range query string false "Server-anchored duration (default 24h, max 720h); exclusive with since/until/pageCursor"
// @Param since query int false "Window start epoch ms; provide with until (default last 24h)"
// @Param until query int false "Exclusive window end epoch ms; maximum span 30d, end may be up to 5 minutes ahead of server time (clock skew tolerance)"
// @Param pageCursor query string false "Opaque precise cursor from nextPageCursor; window is pinned"
// @Param limit query int false "Default 50; positive, capped at 200" minimum(1) maximum(200)
// @Success 200 {object} api.RouteEvidence
// @Failure 400 {object} handlers.APIError
// @Failure 404 {object} handlers.APIError
// @Failure 500 {object} handlers.APIError
// @Failure 503 {object} handlers.APIError "The lookup took too long: retry after Retry-After seconds"
// @Router /routes/{iata}/{pathKey}/observations [get]
func getRouteEvidence(reader api.Reader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
iata, key := strings.ToUpper(chi.URLParam(r, "iata")), strings.ToLower(chi.URLParam(r, "pathKey"))
if !api.ValidRouteEvidenceKey(iata, key) {
respondError(w, 400, "invalid IATA or route pathKey")
return
}
query, err := routeEvidenceQuery(r, iata, key, time.Now())
if err != nil {
respondError(w, 400, "invalid route evidence window, limit or page cursor")
return
}
result, err := reader.GetRouteEvidence(r.Context(), iata, key, query)
switch {
case errors.Is(err, pgx.ErrNoRows), err == nil && result == nil:
respondError(w, 404, "saved route not found")
case errors.Is(err, api.ErrRouteEvidenceInput):
respondError(w, 400, "invalid route evidence request")
case respondUnavailable(w, err):
case err != nil:
respondError(w, 500, "internal server error")
default:
respond(w, 200, result)
}
}
}