textmachine/platform/internal/httpapi/problem.go

338 lines
16 KiB
Go

package httpapi
import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"time"
"textmachine/platform/internal/reqid"
)
// problem.go: the contract's error model (canon 0.3.0 §Problem, §ErrorCode; companion §2.17).
//
// What a client dispatches on is the machine `code`, never the words: `title` and `detail` are
// written for a developer and a log and a client MUST NOT show either. The phrase the user reads is
// drawn by the CLIENT from the code, in the language of its interface — which is why no wording
// here is a product decision and why none of it is translated.
//
// The vocabulary is TWO-LEVEL and that is the whole extensibility mechanism: the root `code` is
// closed for this version, `cause.code` is not. A new special case is added as a cause and breaks
// no client; inventing a root code is a contract change and belongs to a ratification, not to this
// package.
// Code is a root error code. Closed for contract version 0.3.0.
type Code string
const (
CodeInvalidRequest Code = "invalid_request"
CodeUnauthenticated Code = "unauthenticated"
CodeForbidden Code = "forbidden"
CodeNotFound Code = "not_found"
CodeGone Code = "gone"
CodeRequestTimeout Code = "request_timeout"
CodePayloadTooLarge Code = "payload_too_large"
CodeRunInFlight Code = "run_in_flight"
CodeBookNotReady Code = "book_not_ready"
CodeRunNotStoppable Code = "run_not_stoppable"
CodeRunNotResumable Code = "run_not_resumable"
CodeCeilingUnavailable Code = "ceiling_unavailable"
CodeIdempotencyConflict Code = "idempotency_conflict"
CodeContentRefused Code = "content_refused"
CodeServiceUnavailable Code = "service_unavailable"
CodeInternalError Code = "internal_error"
// The two bank codes of 0.5.0. They exist because their remedies have different ADDRESSEES: a
// refused set is re-decided by the person, an incomplete write is re-sent verbatim by the
// machine (canon §applyBankCorrections).
CodeBankCorrectionsRefused Code = "bank_corrections_refused"
CodeBankCorrectionsIncomplete Code = "bank_corrections_incomplete"
)
// Second-level causes this deployment gives. NOT a closed vocabulary — a client that does not know
// one falls back to the root code and loses only precision — so a value added here is not a
// contract change.
const (
// CauseCeilingReached — THIS RUN is finished with: it stopped at the limit it bought, or spent
// all of it. The remedy is a NEW run (canon §resumeRun).
CauseCeilingReached = "ceiling_reached"
// CauseCreditUnavailable — the run has room left and the ACCOUNT cannot cover the rest of it. The
// remedy is to top up, after which the same run continues; the two never stand in for one another.
CauseCreditUnavailable = "credit_unavailable"
// CauseStopRequested — the run is going and has already been asked to stop, so this call will not
// say it is continuing. The remedy is to wait for the stop and resume the stopped run; it is the
// one cause of this code under which WAITING is the answer.
CauseStopRequested = "stop_requested"
// CauseBoundsMoved — the scale moved between the read and the call.
CauseBoundsMoved = "bounds_moved"
// CauseRePassUnavailable — a re-pass was asked for and there is nothing to re-pass: no
// correction was recorded since the last run, or a resnapshot run has already walked it in
// (canon §RunRequest.re_pass, 0.7.0).
CauseRePassUnavailable = "re_pass_unavailable"
// CauseNotPriced — this deployment has no price projection for the book yet, so it cannot quote an
// order over it. It is a state that CLEARS by itself: the projection lands with the book's reading
// surface, which the materializer owes and retries. Its own cause rather than `bounds_moved`,
// because the remedy is opposite — wait, do not re-read and retry in a loop.
CauseNotPriced = "not_priced"
// CauseChapterOrdersUnavailable — an order phrased in CHAPTERS over a book whose chapter cut is
// not trustworthy enough to sell against (see ingest.ChapterOrdersOffered). The remedy is in the
// options read: `order.chapter_orders` is false and `order.structure` says why.
CauseChapterOrdersUnavailable = "chapter_orders_unavailable"
// CauseCreditHeld — another book of this account holds the credit; `blocked` names it.
CauseCreditHeld = "credit_held"
// CauseRunInFlight — this book has a run of its own going, so there is no start to offer at all,
// whatever the balance covers. It fills `RunOptions.blocked` and names THIS book: the place the
// user can act is the book's own card, where that run is.
//
// ⚠ THE ONE VALUE HERE WHOSE VOCABULARY IS NOT THIS OPEN LIST. As a `cause` the rule above holds;
// as a `Blocked.code` it is the canon's own enum beside `credit_held`, which a client may be
// generated from — so it travels with the contract minor that adds it there, and never ahead of
// it.
CauseRunInFlight = "run_in_flight"
// CauseSourceGone — the book's project directory is not on this deployment any more, so a run
// over it could never move. Its own cause under `book_not_ready` because the remedy is opposite
// to that code's ordinary one: waiting clears an intake that is still running and never brings
// back a directory that is gone.
CauseSourceGone = "source_gone"
// CauseKeyReused / CauseKeyInFlight — the two halves of `Idempotency-Key`.
CauseKeyReused = "key_reused"
CauseKeyInFlight = "key_in_flight"
// CauseCursorInvalid — a cursor from a structure that no longer exists (canon §NextCursor).
CauseCursorInvalid = "cursor_invalid"
// CauseVersionTooOld — a delta watermark that predates a wholesale replacement.
CauseVersionTooOld = "version_too_old"
// The two ways a request is refused before authorization. They are DIFFERENT facts and the
// canon gives them one root code (`forbidden`), so this is the only place they are told apart —
// which is what the second level is for.
CauseClientHeaderMissing = "client_header_missing"
CauseOriginRejected = "origin_rejected"
)
// Item codes of `errors[]` (canon §ErrorItem). Not closed either.
const (
ItemMissing = "missing"
ItemMissingOrLate = "missing_or_late"
ItemMalformed = "malformed"
ItemTooLong = "too_long"
ItemUnsupportedPair = "unsupported_pair"
ItemOutOfRange = "out_of_range"
// ItemUnknown has no producer in this build: the one branch that raised it went with the bank's
// per-term write path (D39.144). Kept because this block mirrors the canon's vocabulary rather
// than this build's use of it.
ItemUnknown = "unknown"
// The two answers the intake's own cut gives about the file. Told apart because the remedies are
// opposite; `errors[]` is an open vocabulary by the canon's own words.
//
// ItemNoBook — read and cut, and there is no book in it. The remedy is a different file.
ItemNoBook = "no_book"
// ItemNoChapterStructure — the book is there and nothing cut it into chapters, so a reader would
// get one document of any length. Temporary and about our reach (backlog rows 283/325).
ItemNoChapterStructure = "no_chapter_structure"
)
// codes is the closed root vocabulary of this contract version: every code, the status it names and
// its developer-facing title, in ONE table.
//
// One table because the two are a PAIR: the canon says each code names its status, and a code whose
// status and title were written in two switches is the class of divergence a reviewer cannot see —
// both halves look right on their own. The title is never shown to a user and never translated; the
// sentence a reader gets is the client's, drawn from the code.
var codes = map[Code]struct {
status int
title string
}{
CodeInvalidRequest: {http.StatusBadRequest, "Invalid request"},
CodeContentRefused: {http.StatusBadRequest, "Content refused"},
CodeUnauthenticated: {http.StatusUnauthorized, "Unauthenticated"},
CodeForbidden: {http.StatusForbidden, "Forbidden"},
CodeNotFound: {http.StatusNotFound, "Not found"},
CodeGone: {http.StatusGone, "Gone"},
CodeRequestTimeout: {http.StatusRequestTimeout, "Request timeout"},
CodePayloadTooLarge: {http.StatusRequestEntityTooLarge, "Payload too large"},
CodeRunInFlight: {http.StatusConflict, "Run in flight"},
CodeBookNotReady: {http.StatusConflict, "Book not ready"},
CodeRunNotStoppable: {http.StatusConflict, "Run not stoppable"},
CodeRunNotResumable: {http.StatusConflict, "Run not resumable"},
CodeCeilingUnavailable: {http.StatusConflict, "Ceiling unavailable"},
CodeIdempotencyConflict: {http.StatusConflict, "Idempotency conflict"},
CodeServiceUnavailable: {http.StatusServiceUnavailable, "Service unavailable"},
CodeInternalError: {http.StatusInternalServerError, "Internal error"},
CodeBankCorrectionsRefused: {http.StatusConflict, "Bank corrections refused"},
CodeBankCorrectionsIncomplete: {http.StatusServiceUnavailable, "Bank corrections incomplete"},
}
func statusOf(c Code) int {
if d, ok := codes[c]; ok {
return d.status
}
return http.StatusInternalServerError
}
func title(c Code) string {
if d, ok := codes[c]; ok {
return d.title
}
return "Internal error"
}
// Problem is an RFC 9457 error body with the contract's extension members.
//
// `type` stays `about:blank`: a URI that resolves nowhere is a promise we do not keep and a URN
// that repeats `code` is a second copy of one fact (RFC 9457 §3.1.1 wants `type` to be the
// identifier, §3.2 allows extension members to be it instead — companion §2.17).
//
// Detail NEVER carries engine or database text. The engine's own detail strings read like "CJK leak
// in the ru output: 第一节" — pipeline vocabulary that must not cross this seam (canon §Boundaries).
type Problem struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Code Code `json:"code,omitempty"`
RequestID string `json:"request_id"`
// Cause is the narrower reason within Code, when there is one to give.
Cause *Cause `json:"cause,omitempty"`
// Errors names the parts of the request that were wrong. Carried by `invalid_request`; absent
// where there is no field to point at — a form with too many parts is reported by the root code
// alone (canon §ErrorItem).
Errors []Item `json:"errors,omitempty"`
// Blocked is carried by `ceiling_unavailable` when another book of the account holds the credit.
Blocked *Blocked `json:"blocked,omitempty"`
// Refusals is carried by `bank_corrections_refused`: one entry per refused correction, or one
// about the would-be result as a whole (canon §CorrectionRefusal).
Refusals []CorrectionRefusal `json:"refusals,omitempty"`
// RetryAfter is not a member: it is the header of the same name, and it is here so one writer
// sets it (canon §Conflict).
RetryAfter time.Duration `json:"-"`
}
// Cause is the second level of the code.
type Cause struct {
Code string `json:"code"`
}
// Item is one thing wrong with the request: a JSON Pointer at the member, or `/<name>` naming a
// form part, plus a machine code for it.
type Item struct {
Pointer string `json:"pointer"`
Code string `json:"code"`
}
// Blocked says what is holding the run scale down and which book is doing it. The same shape
// `RunOptions` answers with, because it is the same fact.
type Blocked struct {
Code string `json:"code"`
BookID string `json:"book_id"`
}
// CorrectionRefusal is one refused correction: a JSON Pointer at it (`/corrections/3`), or the
// EMPTY string for a refusal about the result as a whole. `detail` is developer-facing free text,
// like Problem's own — a client marks the row and draws a neutral phrase (canon §CorrectionRefusal).
type CorrectionRefusal struct {
Pointer string `json:"pointer"`
Detail string `json:"detail"`
}
// Fail writes an error identified by its root code.
func Fail(w http.ResponseWriter, r *http.Request, c Code) {
WriteProblem(w, r, Problem{Code: c})
}
// FailCause writes an error with its narrower cause.
func FailCause(w http.ResponseWriter, r *http.Request, c Code, cause string) {
WriteProblem(w, r, Problem{Code: c, Cause: &Cause{Code: cause}})
}
// Invalid writes a 400 naming the parts of the request that were wrong.
func Invalid(w http.ResponseWriter, r *http.Request, items ...Item) {
WriteProblem(w, r, Problem{Code: CodeInvalidRequest, Errors: items})
}
// WriteProblem renders an error response.
//
// The status comes from the code and the title from the code, so neither can disagree with it. A
// caller that has something to add fills Detail (for the log), Cause, Errors or Blocked.
func WriteProblem(w http.ResponseWriter, r *http.Request, p Problem) {
// The versioned surface answers a code on every response, `500` included. `Code` is omitempty
// only for the ungoverned surface below, and without this the omission leaked back here as a
// silent 500 with no code at all.
if p.Code == "" {
p.Code = CodeInternalError
}
p.Type = "about:blank"
p.Status = statusOf(p.Code)
p.Title = title(p.Code)
if r != nil {
p.RequestID = reqid.FromContext(r.Context())
}
if p.RequestID == "" {
// The middleware stamps every request, so an empty id means this response was produced
// outside it — a test double, or a handler mounted above reqid.Middleware. The field is
// required by the schema and a client MAY show it, so it gets a value rather than an empty
// string that reads as "this request had no id".
p.RequestID = reqid.New()
}
h := w.Header()
h.Set("Content-Type", "application/problem+json")
if p.Status == http.StatusUnauthorized {
// RFC 9110 §15.5.2 requires a challenge on every 401, and the canon declares the header
// REQUIRED on that response. Bearer is the scheme this API actually accepts; the session
// cookie is not an HTTP authentication scheme and cannot be named in a challenge. A client
// holding no session sends the user to the sign-in flow rather than parsing this.
h.Set("WWW-Authenticate", `Bearer realm="textmachine"`)
}
if p.RetryAfter > 0 {
h.Set("Retry-After", strconv.Itoa(int(p.RetryAfter.Round(time.Second).Seconds())))
}
w.WriteHeader(p.Status)
if err := json.NewEncoder(w).Encode(p); err != nil {
slog.Debug("problem body not delivered", "err", err) // the client went away mid-write
}
}
// WriteStatusProblem is the error writer for the surfaces the contract does NOT govern: the sign-in
// mechanics under /auth and the operational endpoints.
//
// ⚠ It emits NO `code`, and that is a decision rather than a gap. The sign-in surface has no machine
// vocabulary: a client shows ONE neutral phrase for every refusal there, with a single exception that
// needs no vocabulary at all — `429`, whose remedy travels in `Retry-After`, which is exactly what
// HTTP has that header for. Ratified in the companion's §2.14: «различать причины отказа клиент не
// может по замыслу».
//
// The transport defences that run BEFORE any handler — origin and marker-header checks — still
// answer the versioned `forbidden`: they guard every surface alike, and a defence that answered
// differently by path would be a second rule to keep in step.
//
// One writer per surface and no INVERSE function: the versioned surface derives its status from a
// code, this one takes the status it means at the call site. A status→code inverse would be a second
// source of truth — a code added to `statusOf` would never appear in it.
func WriteStatusProblem(w http.ResponseWriter, r *http.Request, status int, developerTitle, detail string) {
p := Problem{Type: "about:blank", Status: status, Title: developerTitle, Detail: detail}
if r != nil {
p.RequestID = reqid.FromContext(r.Context())
}
if p.RequestID == "" {
p.RequestID = reqid.New()
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(p); err != nil {
slog.Debug("problem body not delivered", "err", err) // the client went away mid-write
}
}
// ProblemHandler is a static problem response, for the middleware that must be handed a denier.
func ProblemHandler(c Code) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Fail(w, r, c)
})
}
// CauseHandler is the same for a denier that has a narrower cause to give — the two ways a request
// is refused before authorization (canon §Forbidden).
func CauseHandler(c Code, cause string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
FailCause(w, r, c, cause)
})
}