35c6ff5a71
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.
119 lines
4.7 KiB
Go
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,
|
|
})
|
|
}
|