976f5238f8
- internal .env loader (real env wins) + strictly validated config struct - ULID package: 48-bit ms timestamp + 80-bit crypto randomness, sortable - HTTP server with recovery/request-id/trusted-proxy/access-log middleware - /healthz, /readyz (pluggable checkers), /metricsz (counter registry) - multi-stage Dockerfile (alpine, non-root) + compose with healthchecks, app bound to 127.0.0.1:8787, mongo unpublished, graceful 15s shutdown Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
123 lines
3.4 KiB
Go
123 lines
3.4 KiB
Go
package httpx
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/netip"
|
|
"runtime/debug"
|
|
"time"
|
|
|
|
"bountyboard/internal/ulid"
|
|
)
|
|
|
|
type ctxKey int
|
|
|
|
const (
|
|
ctxKeyRequestID ctxKey = iota
|
|
ctxKeyClient
|
|
)
|
|
|
|
// RequestID returns the id assigned to this request by the middleware chain.
|
|
func RequestID(ctx context.Context) string {
|
|
id, _ := ctx.Value(ctxKeyRequestID).(string)
|
|
return id
|
|
}
|
|
|
|
// Client returns the trusted-proxy-resolved client info for this request.
|
|
func Client(ctx context.Context) clientInfo {
|
|
c, _ := ctx.Value(ctxKeyClient).(clientInfo)
|
|
return c
|
|
}
|
|
|
|
// ClientIP is a convenience accessor used by rate limiting and audit logging.
|
|
func ClientIP(ctx context.Context) netip.Addr { return Client(ctx).IP }
|
|
|
|
// statusRecorder captures the response status for logging and metrics while
|
|
// staying compatible with http.ResponseController (Unwrap).
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (r *statusRecorder) WriteHeader(code int) {
|
|
if r.status == 0 {
|
|
r.status = code
|
|
}
|
|
r.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (r *statusRecorder) Write(b []byte) (int, error) {
|
|
if r.status == 0 {
|
|
r.status = http.StatusOK
|
|
}
|
|
return r.ResponseWriter.Write(b)
|
|
}
|
|
|
|
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
|
|
|
func (s *Server) withMiddleware(next http.Handler) http.Handler {
|
|
return s.recoverPanic(s.assignRequestID(s.resolveClientInfo(s.accessLog(next))))
|
|
}
|
|
|
|
// recoverPanic guarantees no panic escapes a request path: it logs the stack
|
|
// and returns the standard JSON error envelope.
|
|
func (s *Server) recoverPanic(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
if rec == http.ErrAbortHandler { // client went away mid-write
|
|
panic(rec)
|
|
}
|
|
s.log.Error("panic in request handler",
|
|
"err", fmt.Sprint(rec),
|
|
"requestId", RequestID(r.Context()),
|
|
"path", r.URL.Path,
|
|
"stack", string(debug.Stack()))
|
|
s.metrics.Inc("http_panics_total", 1)
|
|
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) assignRequestID(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id := ulid.New()
|
|
w.Header().Set("X-Request-Id", id)
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id)))
|
|
})
|
|
}
|
|
|
|
func (s *Server) resolveClientInfo(next http.Handler) http.Handler {
|
|
defaultProto := "http"
|
|
if r := s.cfg.AppBaseURL; len(r) >= 8 && r[:8] == "https://" {
|
|
defaultProto = "https"
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
info := resolveClient(r, s.cfg.TrustedProxyCIDRs, defaultProto)
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyClient, info)))
|
|
})
|
|
}
|
|
|
|
func (s *Server) accessLog(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
rec := &statusRecorder{ResponseWriter: w}
|
|
next.ServeHTTP(rec, r)
|
|
if rec.status == 0 {
|
|
rec.status = http.StatusOK
|
|
}
|
|
s.metrics.Inc("http_requests_total", 1)
|
|
s.metrics.Inc(fmt.Sprintf("http_responses_%dxx_total", rec.status/100), 1)
|
|
s.log.Info("request",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", rec.status,
|
|
"durationMs", time.Since(start).Milliseconds(),
|
|
"clientIp", Client(r.Context()).IP.String(),
|
|
"requestId", RequestID(r.Context()))
|
|
})
|
|
}
|