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.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user