Fixes B1 and B2 of plans/2026-07-11-nomos-agent-code-review.md together, since the right granularity for B1 in the auto-continuation worker turned out to require B2's restructuring anyway (see below). B1: grep -rn "recover()" cmd/nomos/ internal/mcp/ internal/httpapi/ returned nothing before this — every explicitly-spawned goroutine (continuation worker, resumed chat turns, async execution dispatch, the SSE listener, two duplicate sshExec implementations' output-collector goroutines) crashed the whole process on an unhandled panic, not just that one goroutine. More consequential post-concurrency: more simultaneous unattended background work means more surface area for one bad input to end every running task. New internal/safego package: Go(label, fn) launches fn in a goroutine with a recover-and-log wrapper. Applied at every bare `go` spawn site across the three packages. Two sites needed bespoke handling instead of the generic helper because their callers block on a channel and a silent recover would just make them hang until timeout: sshExec's output-collector goroutine (two near-identical copies, internal/mcp/server.go and internal/httpapi/phase3.go) and httpapi's ListenAndServe goroutine — both now recover AND send a synthetic error result so the waiting select unblocks immediately instead of waiting out the full timeout. httpapi's sseListener got extra treatment: its per-notification handling was extracted into handleNotification with its own recover, so a panic decoding ONE malformed pg_notify payload can't kill the listener goroutine for every connected SSE client — the outer goroutine spawn only needs to guard the connection setup/reconnect code around it. B2: cmd/nomos/continue.go's processContinuations used to run every pending continuation SEQUENTIALLY in a plain for loop, in the SAME goroutine as the ticker — meaning (a) task B's continuation waited for task A's full (up to 10-minute) resumed turn to finish first, undercutting this session's earlier concurrency work on exactly the path autonomous tasks depend on most, and (b) an unrecovered panic anywhere in that call chain didn't just crash the process (B1) — even WITH B1's recovery wrapped only at the top-level worker spawn, the panic would still unwind the ENTIRE ticker-loop goroutine, silently ending auto-continuation for every task until nomos restarted. Fixed by spawning each pending item via safego.Go individually: real parallelism, and a bad item can now only ever take down its own goroutine. Added internal/safego/safego_test.go: TestGo_RecoversPanic is the concrete proof — a deliberate panic inside Go() that would otherwise crash the whole test binary; reaching the assertion after it IS the evidence recovery works. Verified live against the rebuilt containers: full chat turn round-tripped correctly (hostname lookup, 2 iterations, normal completion) — no regression from threading safego.Go through the tool-dispatch/continuation paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
561 lines
17 KiB
Go
561 lines
17 KiB
Go
// Package httpapi implements the Oikos REST API. The contract is
|
|
// api/openapi.yaml (contract-first, ADR-0004); handlers implement the
|
|
// oapi-codegen strict-server interface in gen/. Errors map to RFC 9457
|
|
// problem+json via domain sentinels.
|
|
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rsa"
|
|
"crypto/subtle"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/config"
|
|
"github.com/dtoro/oikos/internal/db"
|
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
|
mcphandler "github.com/dtoro/oikos/internal/mcp"
|
|
"github.com/dtoro/oikos/internal/safego"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// contextKey for storing actor identity in request context.
|
|
type contextKey string
|
|
|
|
const actorKey contextKey = "oikos_actor"
|
|
|
|
// actor holds the resolved identity of the API caller.
|
|
type actor struct {
|
|
Type string // "operator", "agent", "system"
|
|
Label string // human-readable label
|
|
ID string // OIDC sub or static token identifier
|
|
TokenType string // "static" or "oidc"
|
|
}
|
|
|
|
// Server implements gen.StrictServerInterface over the DB layer.
|
|
type Server struct {
|
|
pool *db.Pool
|
|
cfg config.Config
|
|
secretsManager secretsBackend
|
|
sseBroker *sseBroker
|
|
sseSubs map[*sseSubscriber]struct{}
|
|
sseMu sync.Mutex
|
|
}
|
|
|
|
// secretsBackend is a minimal interface for secrets operations used by the
|
|
// HTTP API (enrollment key storage, listing). Compatible with internal/secrets.
|
|
type secretsBackend interface {
|
|
Set(ctx context.Context, key string, value string) error
|
|
List(ctx context.Context) ([]string, error)
|
|
}
|
|
|
|
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
|
|
// SG18) + the OpenAPI surface under /api/v1 behind bearer auth.
|
|
//
|
|
// ctx governs the lifetime of the background SSE listener goroutine, which
|
|
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
|
|
// before closing the pool — otherwise the held connection never releases
|
|
// and pool.Close() deadlocks.
|
|
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) http.Handler {
|
|
s := &Server{
|
|
pool: pool,
|
|
cfg: cfg,
|
|
sseBroker: newSSEBroker(10000),
|
|
sseSubs: make(map[*sseSubscriber]struct{}),
|
|
}
|
|
|
|
// Start background SSE listener, tied to ctx for clean shutdown.
|
|
// handleNotification (called per-message inside sseListener's loop) has
|
|
// its own recover for the common case; this outer one covers the
|
|
// connection-setup/reconnect code around it.
|
|
safego.Go("httpapi:sse-listener", func() { s.sseListener(ctx) })
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(middleware.RequestID)
|
|
r.Use(requestLogger)
|
|
|
|
// Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
|
|
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second)
|
|
defer cancel()
|
|
if err := pool.Ping(ctx); err != nil {
|
|
writeProblem(w, req, http.StatusServiceUnavailable, "database unreachable", "")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
})
|
|
|
|
// Client enrollment — unauthenticated (IP-gated in handler).
|
|
// Registered on base router BEFORE HandlerWithOptions so it
|
|
// bypasses the combinedAuth middleware applied to all /api/v1/*.
|
|
r.Post("/api/v1/clients/enroll", func(w http.ResponseWriter, req *http.Request) {
|
|
var body gen.EnrollRequest
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
|
return
|
|
}
|
|
resp, err := s.EnrollClient(req.Context(), gen.EnrollClientRequestObject{Body: &body})
|
|
if err != nil {
|
|
writeProblemFromErr(w, req, err)
|
|
return
|
|
}
|
|
if err := resp.VisitEnrollClientResponse(w); err != nil {
|
|
writeProblem(w, req, http.StatusInternalServerError, "response encoding failed", err.Error())
|
|
}
|
|
})
|
|
|
|
strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
|
|
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
|
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
|
|
},
|
|
ResponseErrorHandlerFunc: writeProblemFromErr,
|
|
})
|
|
|
|
gen.HandlerWithOptions(strict, gen.ChiServerOptions{
|
|
BaseURL: "/api/v1",
|
|
BaseRouter: r,
|
|
Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg)},
|
|
ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
|
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
|
|
},
|
|
})
|
|
|
|
// SSE stream: override the generated /events/stream route with a raw
|
|
// flushing handler (registered AFTER HandlerWithOptions so chi's last
|
|
// registration wins). The strict-server path can't Flush() per event;
|
|
// this one uses the real ResponseWriter for real-time delivery. It
|
|
// inherits the router's base middleware and applies auth via With().
|
|
r.With(combinedAuth(cfg)).Get("/api/v1/events/stream", s.serveSSE)
|
|
|
|
// Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the
|
|
// Knowledge page's "what the system has learned" view. Registered after
|
|
// HandlerWithOptions so it wins over any generated catch-all.
|
|
r.With(combinedAuth(cfg)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
|
|
|
|
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
|
|
// unlike ListExecutions which sorts by target for pagination) and the
|
|
// per-session "what did this session do" digest.
|
|
r.With(combinedAuth(cfg)).Get("/api/v1/activity/recent", s.serveRecentActivity)
|
|
r.With(combinedAuth(cfg)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
|
|
|
|
// Learning view: capability timeline + success trend, both derived from
|
|
// executions (real, growing data) rather than the patterns/skills tables,
|
|
// which are correctly modeled but have no writers anywhere yet.
|
|
r.With(combinedAuth(cfg)).Get("/api/v1/learning/timeline", s.serveLearningTimeline)
|
|
r.With(combinedAuth(cfg)).Get("/api/v1/learning/trend", s.serveLearningTrend)
|
|
|
|
// Mount MCP at /mcp (plan R3-10)
|
|
nomosAgentID := uuid.Nil
|
|
if cfg.NomosAgentID != "" {
|
|
if id, err := uuid.Parse(cfg.NomosAgentID); err == nil {
|
|
nomosAgentID = id
|
|
}
|
|
}
|
|
if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
|
|
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID)
|
|
}
|
|
r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID))
|
|
|
|
r.Get("/ui/*", func(w http.ResponseWriter, req *http.Request) {
|
|
if uiHandler != nil {
|
|
uiHandler.ServeHTTP(w, req)
|
|
}
|
|
})
|
|
r.Get("/ui", func(w http.ResponseWriter, req *http.Request) {
|
|
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
|
|
})
|
|
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
|
|
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
|
|
})
|
|
|
|
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
|
|
target, _ := url.Parse(nomosURL)
|
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
|
r.Mount("/agent", http.StripPrefix("/agent", proxy))
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
// combinedAuth tries OIDC JWT validation first (if configured), falls back to
|
|
// static bearer token validation, and opens the gate in dev mode when no
|
|
// credentials are configured.
|
|
func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
|
|
hasOIDC := cfg.OIDCIssuer != "" && cfg.OIDCClientID != ""
|
|
hasStatic := cfg.APIToken != "" || cfg.MCPBearerToken != ""
|
|
|
|
// Cache JWKS for OIDC
|
|
var jwksURL string
|
|
var jwksCache []jwtVerificationKey
|
|
var jwksMu sync.RWMutex
|
|
if hasOIDC {
|
|
// Fetch JWKS URI from OIDC discovery
|
|
jwksURL = discoverJWKSURI(cfg.OIDCIssuer)
|
|
if jwksURL != "" {
|
|
keys, err := fetchJWKS(jwksURL)
|
|
if err != nil {
|
|
slog.Warn("oidc initial jwks fetch failed, will retry on demand", "error", err)
|
|
} else {
|
|
jwksCache = keys
|
|
}
|
|
}
|
|
}
|
|
|
|
var staticTokens [][]byte
|
|
if cfg.APIToken != "" {
|
|
staticTokens = append(staticTokens, []byte(cfg.APIToken))
|
|
}
|
|
if cfg.MCPBearerToken != "" {
|
|
staticTokens = append(staticTokens, []byte(cfg.MCPBearerToken))
|
|
}
|
|
|
|
devOpen := cfg.APIEnv == "dev" && !hasStatic && !hasOIDC
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if devOpen {
|
|
ctx := context.WithValue(r.Context(), actorKey, actor{
|
|
Type: "system",
|
|
Label: "dev:anonymous",
|
|
ID: "dev",
|
|
TokenType: "none",
|
|
})
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
auth := r.Header.Get("Authorization")
|
|
raw, ok := strings.CutPrefix(auth, "Bearer ")
|
|
if !ok || raw == "" {
|
|
writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
|
|
"missing bearer token")
|
|
return
|
|
}
|
|
|
|
// Try OIDC first if configured
|
|
if hasOIDC {
|
|
jwksMu.RLock()
|
|
keys := jwksCache
|
|
jwksMu.RUnlock()
|
|
|
|
// If cache is empty, try to refresh
|
|
if len(keys) == 0 && jwksURL != "" {
|
|
if freshKeys, err := fetchJWKS(jwksURL); err == nil {
|
|
jwksMu.Lock()
|
|
jwksCache = freshKeys
|
|
keys = freshKeys
|
|
jwksMu.Unlock()
|
|
}
|
|
}
|
|
|
|
if act, err := validateOIDCToken(raw, cfg, keys); err == nil {
|
|
ctx := context.WithValue(r.Context(), actorKey, act)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
} else {
|
|
slog.Debug("oidc validation failed", "error", err)
|
|
}
|
|
}
|
|
|
|
// Fall back to static tokens
|
|
if hasStatic {
|
|
for _, t := range staticTokens {
|
|
if subtle.ConstantTimeCompare([]byte(raw), t) == 1 {
|
|
label := "operator:api"
|
|
if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 {
|
|
label = "agent:mcp"
|
|
}
|
|
ctx := context.WithValue(r.Context(), actorKey, actor{
|
|
Type: label[:strings.IndexByte(label, ':')],
|
|
Label: label,
|
|
ID: raw[:8] + "...",
|
|
TokenType: "static",
|
|
})
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
|
|
"invalid or expired bearer token")
|
|
})
|
|
}
|
|
}
|
|
|
|
// jwtVerificationKey holds a parsed RSA public key or HMAC secret for JWT
|
|
// verification, identified by its key ID (kid).
|
|
type jwtVerificationKey struct {
|
|
Kid string
|
|
Alg string
|
|
Key any // *rsa.PublicKey or []byte for HMAC
|
|
IsHMAC bool
|
|
}
|
|
|
|
// discoverJWKSURI fetches the OIDC discovery document and extracts the
|
|
// jwks_uri field.
|
|
func discoverJWKSURI(issuerURL string) string {
|
|
discURL := strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
|
|
client := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
|
|
}}
|
|
resp, err := client.Get(discURL)
|
|
if err != nil {
|
|
slog.Warn("oidc discovery failed", "url", discURL, "error", err)
|
|
return ""
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var disc struct {
|
|
JWKSURI string `json:"jwks_uri"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&disc); err != nil {
|
|
slog.Warn("oidc discovery decode failed", "error", err)
|
|
return ""
|
|
}
|
|
if disc.JWKSURI == "" {
|
|
slog.Warn("oidc discovery missing jwks_uri")
|
|
return ""
|
|
}
|
|
return disc.JWKSURI
|
|
}
|
|
|
|
// fetchJWKS retrieves the JWK set from a URL and returns the parsed keys.
|
|
func fetchJWKS(jwksURL string) ([]jwtVerificationKey, error) {
|
|
client := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
|
|
}}
|
|
resp, err := client.Get(jwksURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch jwks: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var jwks struct {
|
|
Keys []json.RawMessage `json:"keys"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
|
|
return nil, fmt.Errorf("decode jwks: %w", err)
|
|
}
|
|
|
|
var keys []jwtVerificationKey
|
|
for _, raw := range jwks.Keys {
|
|
var header struct {
|
|
Kty string `json:"kty"`
|
|
Kid string `json:"kid,omitempty"`
|
|
Alg string `json:"alg,omitempty"`
|
|
Use string `json:"use,omitempty"`
|
|
}
|
|
if err := json.Unmarshal(raw, &header); err != nil {
|
|
continue
|
|
}
|
|
// Skip keys not intended for signature verification
|
|
if header.Use != "" && header.Use != "sig" {
|
|
continue
|
|
}
|
|
|
|
var key jwtVerificationKey
|
|
key.Kid = header.Kid
|
|
key.Alg = header.Alg
|
|
|
|
switch header.Kty {
|
|
case "RSA":
|
|
var rsaKey struct {
|
|
N string `json:"n"`
|
|
E string `json:"e"`
|
|
}
|
|
if err := json.Unmarshal(raw, &rsaKey); err != nil {
|
|
continue
|
|
}
|
|
pubKey, err := parseRSAPublicKey(rsaKey.N, rsaKey.E)
|
|
if err != nil {
|
|
slog.Debug("oidc parse rsa key failed", "kid", header.Kid, "error", err)
|
|
continue
|
|
}
|
|
key.Key = pubKey
|
|
case "oct":
|
|
// HMAC keys not expected for OIDC but handle gracefully
|
|
key.IsHMAC = true
|
|
default:
|
|
continue
|
|
}
|
|
|
|
if key.Key != nil || key.IsHMAC {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
|
|
if len(keys) == 0 {
|
|
return nil, fmt.Errorf("no usable keys in jwks")
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
// parseRSAPublicKey decodes a base64url-encoded RSA modulus and exponent into
|
|
// an *rsa.PublicKey.
|
|
func parseRSAPublicKey(nB64, eB64 string) (any, error) {
|
|
// Decode base64url modulus
|
|
nBytes, err := base64.RawURLEncoding.DecodeString(nB64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode modulus: %w", err)
|
|
}
|
|
// Decode base64url exponent
|
|
eBytes, err := base64.RawURLEncoding.DecodeString(eB64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode exponent: %w", err)
|
|
}
|
|
|
|
// Build RSA public key
|
|
n := new(big.Int).SetBytes(nBytes)
|
|
e := 0
|
|
for _, b := range eBytes {
|
|
e = (e << 8) | int(b)
|
|
}
|
|
return &rsa.PublicKey{N: n, E: e}, nil
|
|
}
|
|
|
|
// validateOIDCToken parses and validates a JWT Bearer token against the OIDC
|
|
// configuration. Returns the resolved actor on success.
|
|
func validateOIDCToken(rawToken string, cfg config.Config, keys []jwtVerificationKey) (actor, error) {
|
|
keyFunc := func(token *jwt.Token) (any, error) {
|
|
kid, ok := token.Header["kid"].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("no kid in token header")
|
|
}
|
|
|
|
// Find matching key
|
|
for _, k := range keys {
|
|
// If kid is empty in JWK, try algorithm match
|
|
if k.Kid == kid || (k.Kid == "" && k.Alg == token.Header["alg"]) {
|
|
return k.Key, nil
|
|
}
|
|
}
|
|
// Fall back to any RSA key if no kid match (some providers don't set kid)
|
|
if !ok {
|
|
for _, k := range keys {
|
|
if k.Key != nil {
|
|
return k.Key, nil
|
|
}
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("no matching key for kid: %s", kid)
|
|
}
|
|
|
|
token, err := jwt.Parse(rawToken, keyFunc,
|
|
jwt.WithIssuer(cfg.OIDCIssuer),
|
|
jwt.WithAudience(cfg.OIDCClientID),
|
|
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}),
|
|
)
|
|
if err != nil {
|
|
return actor{}, fmt.Errorf("jwt validation: %w", err)
|
|
}
|
|
|
|
claims, ok := token.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return actor{}, fmt.Errorf("invalid claims")
|
|
}
|
|
|
|
sub, _ := claims.GetSubject()
|
|
if sub == "" {
|
|
// Try the Azure/Entra ID oid claim fallback
|
|
if oid, ok := claims["oid"].(string); ok {
|
|
sub = oid
|
|
}
|
|
}
|
|
|
|
preferredUsername, _ := claims["preferred_username"].(string)
|
|
email, _ := claims["email"].(string)
|
|
|
|
label := sub
|
|
if preferredUsername != "" {
|
|
label = preferredUsername
|
|
} else if email != "" {
|
|
label = email
|
|
}
|
|
|
|
return actor{
|
|
Type: "operator",
|
|
Label: label,
|
|
ID: sub,
|
|
TokenType: "oidc",
|
|
}, nil
|
|
}
|
|
|
|
// GetActor extracts the actor identity from the context. Returns nil if not
|
|
// set (should not happen for authenticated routes).
|
|
func GetActor(ctx context.Context) *actor {
|
|
a, ok := ctx.Value(actorKey).(actor)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return &a
|
|
}
|
|
|
|
// requestLogger logs one line per request with method, path, status,
|
|
// duration, and the chi request id.
|
|
func requestLogger(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
next.ServeHTTP(ww, r)
|
|
slog.Info("http",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", ww.Status(),
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
"request_id", middleware.GetReqID(r.Context()),
|
|
)
|
|
})
|
|
}
|
|
|
|
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
|
|
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
|
|
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) error {
|
|
srv := &http.Server{
|
|
Addr: cfg.APIListen,
|
|
Handler: NewHandler(ctx, pool, cfg, uiHandler),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
// Recovers a panic in ListenAndServe (stdlib, so extremely unlikely,
|
|
// but an unrecovered panic here would crash the whole process rather
|
|
// than surfacing as a normal startup error) and reports it through
|
|
// errCh instead — the select below would otherwise just hang waiting
|
|
// for a value that never arrives.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
errCh <- fmt.Errorf("panic in ListenAndServe: %v", r)
|
|
}
|
|
}()
|
|
slog.Info("api listening", "addr", cfg.APIListen)
|
|
errCh <- srv.ListenAndServe()
|
|
}()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
return err
|
|
case <-ctx.Done():
|
|
slog.Info("api shutting down")
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
return srv.Shutdown(shutdownCtx)
|
|
}
|
|
} |