Files
verdande/internal/httpapi/errors.go
T
Kristian WindandClaude Opus 5 c4e480a0c4
Release / Build and publish image (push) Failing after 10m6s
fix(gmail): stop hiding why it failed, twice over
Reported from use: "Fetch now" said "something went wrong" and the error log was
empty. Both halves were the same bug wearing two hats.

`handleGmailSyncNow` answered 502 with the code `internal_error`, which the
interface maps to a generic sentence — so Google's own words, which are the whole
diagnosis, were replaced by one that says nothing. And `writeError` records
nothing; only `s.internal` does. So there was no message to read and nowhere to
look it up afterwards.

`s.upstream()` is the answer: 502 because this is somebody else's service saying
no rather than this server breaking, a code of its own so the interface can quote
what was said, and a row in the error log — which is the entire reason that log
exists. A Gmail sync failing quietly for a week is otherwise invisible: the
background job writes a line the next restart erases.

The same connection also left the mailbox unnamed. The address is learned at
connect time and that call can fail; a warning went to the log and an empty string
to the database, so the page said "an unknown account" and kept saying it. The only
way back was to disconnect and reconnect, which nobody would guess. The status
endpoint asks again when it finds none, so a wrong answer lasts until the next look
rather than until somebody reconnects.

Ten Danish strings were hiding in `confirm()` and `app.toast()` calls, including
every destructive confirmation in the app — the worst possible set to leave in one
language, since it is what somebody reads in the half-second before agreeing to
lose something. The i18n guard reads markup and attributes, and a string argument
lives in the script block. It reads those now, and found four my own grep missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:28:44 +02:00

147 lines
5.7 KiB
Go

package httpapi
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
)
// APIError is the one shape every failure takes. A client that has learned to read
// it once can read all of them.
//
// `code` is a stable machine-readable string; `error` is English prose for a
// developer reading a log. Neither is what the user sees — the frontend maps `code`
// to a Danish or English message, because the server has no business deciding which
// language somebody's browser is in.
type APIError struct {
Code string `json:"code"`
Message string `json:"error"`
Fields map[string]string `json:"fields,omitempty"`
}
func (e APIError) Error() string { return e.Message }
// The codes the API can return. Kept in one list so the frontend's mapping can be
// checked against it rather than discovered from whatever a handler happened to write.
const (
CodeBadRequest = "bad_request"
CodeValidation = "validation_failed"
CodeUnauthorized = "unauthorized"
CodeTOTPRequired = "totp_required"
CodeForbidden = "forbidden"
CodeNotFound = "not_found"
CodeConflict = "conflict"
CodeRateLimited = "rate_limited"
CodeInternal = "internal_error"
CodePayloadTooLarge = "payload_too_large"
// Four situations that used to answer plain `conflict`, which the frontend
// renders as "that already exists". For these that is not merely vague, it is
// wrong: an unconfigured Gmail is not a Gmail that is already there, and a
// second factor that is off is not one that is on. A code exists so the
// interface can say the true thing — the server's own prose is English for a
// log, and deciding what a person reads is the frontend's job.
CodeGmailNotConfigured = "gmail_not_configured"
CodeAINotConfigured = "ai_not_configured"
CodeTOTPNotEnabled = "totp_not_enabled"
CodeInboxProtected = "inbox_protected"
// And a fifth, for the same reason: refusing to remove the last administrator
// is not "that already exists" either, and it is the one refusal on the user
// page that a person needs explained rather than merely reported.
CodeLastAdmin = "last_admin"
// Gmail said no. Its own words are the diagnosis — "invalid_grant",
// "insufficient authentication scopes" — and they are useless behind a generic
// sentence, which is what `internal_error` gave: a person clicked Fetch now,
// read "something went wrong", and had nowhere to go. The interface wraps the
// message in one that says where it came from.
CodeGmailFailed = "gmail_failed"
)
// errLastAdmin is returned to the caller that already wrote the response, so it
// stops rather than carrying on. It is never rendered anywhere.
var errLastAdmin = errors.New("httpapi: refused to remove the last administrator")
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, APIError{Code: code, Message: message})
}
func writeFieldErrors(w http.ResponseWriter, fields map[string]string) {
writeJSON(w, http.StatusUnprocessableEntity, APIError{
Code: CodeValidation,
Message: "one or more fields are not valid",
Fields: fields,
})
}
// maxBodyBytes caps a JSON request body at 1 MiB. Task text and comments are small;
// anything larger is either a mistake or an attempt to make the server allocate.
// File uploads do not come through here.
const maxBodyBytes = 1 << 20
// decodeJSON reads a request body strictly: unknown fields are an error rather than
// being ignored.
//
// Silently dropping a field the client sent is the worst of the options — a typo in
// `priorty` becomes "the API accepted my request and ignored half of it", which is
// discovered much later and by the user rather than by the developer.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
if ct := r.Header.Get("Content-Type"); ct != "" {
if mt := strings.TrimSpace(strings.Split(ct, ";")[0]); mt != "application/json" {
writeError(w, http.StatusUnsupportedMediaType, CodeBadRequest,
"request body must be application/json")
return errors.New("wrong content type")
}
}
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
var maxErr *http.MaxBytesError
switch {
case errors.As(err, &maxErr):
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"request body is too large")
case errors.Is(err, io.EOF):
writeError(w, http.StatusBadRequest, CodeBadRequest, "request body is empty")
default:
writeError(w, http.StatusBadRequest, CodeBadRequest, jsonErrorMessage(err))
}
return err
}
// Exactly one JSON value, so a body with a second object appended cannot smuggle
// anything past a handler that only reads the first.
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, CodeBadRequest,
"request body must contain a single JSON object")
return errors.New("trailing data")
}
return nil
}
// jsonErrorMessage turns the standard library's decoder errors into something that
// names the offending field, since "json: cannot unmarshal string into Go struct
// field taskRequest.priority of type int" is not a sentence to put in an API.
func jsonErrorMessage(err error) string {
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
if typeErr.Field != "" {
return "field " + typeErr.Field + " has the wrong type"
}
return "a field has the wrong type"
}
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
return "request body is not valid JSON"
}
if strings.HasPrefix(err.Error(), "json: unknown field ") {
return "unknown field " + strings.TrimPrefix(err.Error(), "json: unknown field ")
}
return "request body could not be read"
}