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 `/` 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) }) }