Files
Maven/cmd/mavweb/problems.go
T
claude 35c6ff5a71 Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
2026-08-13 02:50:59 +04:00

119 lines
4.7 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
)
// problemCode is the stable, low-cardinality name a client can key on. The
// request ID identifies one occurrence; the code identifies the class of
// failure without exposing the wrapped implementation error.
type problemCode string
const (
problemMethodNotAllowed problemCode = "request.method_not_allowed"
problemInvalidRequest problemCode = "request.invalid"
problemPayloadTooLarge problemCode = "request.payload_too_large"
problemUnauthorized problemCode = "auth.unauthorized"
problemStepUpRequired problemCode = "auth.step_up_required"
problemResourceNotFound problemCode = "resource.not_found"
problemIntegrationOff problemCode = "integration.disabled"
problemCoreUnavailable problemCode = "core.unavailable"
problemCoreReadFailed problemCode = "core.read_failed"
problemCoreWriteFailed problemCode = "core.write_failed"
problemCoreChangeFailed problemCode = "core.change_failed"
problemToolsChange problemCode = "tools.change_failed"
problemRoutinesChange problemCode = "routines.change_failed"
problemModelsUnavailable problemCode = "models.unavailable"
problemModelsForbidden problemCode = "models.forbidden"
problemWebAuthnBegin problemCode = "webauthn.begin_failed"
problemWebAuthnFinish problemCode = "webauthn.finish_failed"
problemWebAuthnStepUp problemCode = "webauthn.step_up_failed"
problemVoiceUnavailable problemCode = "voice.unavailable"
problemVoiceTransport problemCode = "voice.transport_failed"
problemVoiceResponse problemCode = "voice.response_failed"
)
type requestIDKey struct{}
// problemLogger is separate from the package-wide logger so the contract test
// can capture exactly one problem line without redirecting unrelated output.
var problemLogger = log.New(os.Stderr, "", log.LstdFlags)
// problemResponse is the one non-success envelope returned by mavweb. Error is
// deliberately a public message, never err.Error(). Code is stable across
// occurrences; request_id joins this answer to the full server-side log line.
type problemResponse struct {
Error string `json:"error"`
Code problemCode `json:"code"`
RequestID string `json:"request_id"`
}
// withRequestID mints the request identifier at the HTTP boundary. A caller's
// X-Request-ID is ignored: accepting it would let an untrusted client forge a
// link to another request's logs. The generated ID is also returned on success,
// which lets an operator start from any surprising response, not errors alone.
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := rand.Text()
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func requestIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey{}).(string)
return id
}
func problemRequestID(r *http.Request) string {
id := requestIDFromContext(r.Context())
if id == "" {
id = rand.Text()
}
return id
}
func logProblem(r *http.Request, status int, code problemCode, public string, err error) string {
id := problemRequestID(r)
if err == nil {
err = errors.New(public)
}
problemLogger.Printf("mavweb problem request_id=%s code=%s status=%d method=%s path=%q: %v",
id, code, status, r.Method, r.URL.Path, err)
return id
}
// inlineProblem preserves a useful partial page when one panel fails, while
// applying the same disclosure and correlation rules as an HTTP problem.
func inlineProblem(r *http.Request, code problemCode, public string, err error) string {
id := logProblem(r, http.StatusOK, code, public, err)
return fmt.Sprintf("%s (code %s, request %s)", public, code, id)
}
// writeProblem is the only mavweb HTTP error writer. The wrapped error is
// logged in full and only the explicit public message, stable code and request
// ID cross the HTTP boundary.
func writeProblem(w http.ResponseWriter, r *http.Request, status int, code problemCode, public string, err error) {
// Unit-level handlers and embedders may call a handler without installing
// the server middleware. They still get the same traceable contract.
id := logProblem(r, status, code, public, err)
w.Header().Set("X-Request-ID", id)
w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
// A failed client connection leaves nowhere useful to report an encoder
// error; the full problem is already in the server log before this write.
_ = json.NewEncoder(w).Encode(problemResponse{
Error: public, Code: code, RequestID: id,
})
}