Files
relata/internal/api/errors.go
T
2026-08-11 10:40:10 +01:00

351 lines
12 KiB
Go

// Package api implements Relata's versioned REST interface under /api/v1.
//
// Handlers here never expose internal detail to clients: stack traces, driver
// errors and file paths stay in the logs, while the response carries a stable
// machine-readable code and a message safe to show a user.
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"github.com/andydixon/relata/internal/analysis"
"github.com/andydixon/relata/internal/auth"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ingest"
"github.com/andydixon/relata/internal/observability"
"github.com/andydixon/relata/internal/search"
)
// Stable error codes. Clients may branch on these; the messages may change.
const (
CodeInvalidRequest = "invalid_request"
CodeValidationFailed = "validation_failed"
CodeUnauthorised = "unauthorised"
CodeForbidden = "forbidden"
CodeNotFound = "not_found"
CodeDuplicate = "duplicate_content"
CodeConflict = "conflict"
CodePayloadTooLarge = "payload_too_large"
CodeUnsupportedMedia = "unsupported_media_type"
CodeRateLimited = "rate_limited"
CodeInsufficientScope = "insufficient_scope"
CodeCSRFFailed = "csrf_check_failed"
CodeDependencyDown = "dependency_unavailable"
CodeDependencyTimeout = "dependency_timeout"
CodeClientClosed = "client_closed_request"
CodeModelInvalid = "model_output_invalid"
CodeFeatureDisabled = "feature_disabled"
CodeInternal = "internal_error"
)
// ErrorResponse is the body returned for every failed API request.
type ErrorResponse struct {
Error ErrorBody `json:"error"`
}
// ErrorBody carries the code, message and any field-level detail.
type ErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Fields []FieldError `json:"fields,omitempty"`
// RequestID lets a user quote a reference when reporting a problem, and
// lets an operator find the corresponding log line.
RequestID string `json:"request_id,omitempty"`
// Detail carries safe supplementary information, such as the ID of an
// existing duplicate item.
Detail map[string]string `json:"detail,omitempty"`
}
// FieldError describes one invalid input field.
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// APIError is an error with an HTTP status and a stable code.
type APIError struct {
Status int
Code string
Message string
Fields []FieldError
Detail map[string]string
// Internal is logged but never sent to the client.
Internal error
}
func (e *APIError) Error() string {
if e.Internal != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Internal)
}
return e.Message
}
func (e *APIError) Unwrap() error { return e.Internal }
// Errorf builds an APIError.
func Errorf(status int, code, format string, args ...any) *APIError {
return &APIError{Status: status, Code: code, Message: fmt.Sprintf(format, args...)}
}
// BadRequest builds a 400.
func BadRequest(msg string, fields ...FieldError) *APIError {
return &APIError{Status: http.StatusBadRequest, Code: CodeValidationFailed, Message: msg, Fields: fields}
}
// WriteError sends an error response, mapping domain errors onto HTTP.
//
// Unmapped errors become a generic 500: the internal detail is logged with the
// request ID but never returned, so a database error cannot leak schema or
// connection information to a client.
func WriteError(w http.ResponseWriter, r *http.Request, err error) {
reqID := observability.RequestID(r.Context())
log := observability.LoggerFrom(r.Context())
var apiErr *APIError
if !errors.As(err, &apiErr) {
apiErr = mapDomainError(err)
}
if apiErr.RequestIDSuppressed() {
reqID = ""
}
if apiErr.Status >= 500 {
log.Error("request failed",
"code", apiErr.Code, "path", r.URL.Path, "method", r.Method,
"error", err.Error())
} else {
log.Info("request rejected",
"code", apiErr.Code, "status", apiErr.Status, "path", r.URL.Path, "method", r.Method,
"reason", apiErr.Message)
}
body := ErrorResponse{Error: ErrorBody{
Code: apiErr.Code,
Message: apiErr.Message,
Fields: apiErr.Fields,
RequestID: reqID,
Detail: apiErr.Detail,
}}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(apiErr.Status)
_ = json.NewEncoder(w).Encode(body)
}
// RequestIDSuppressed reports whether the request ID should be withheld. It
// never is at present, but the hook keeps the decision in one place.
func (e *APIError) RequestIDSuppressed() bool { return false }
// mapDomainError converts a domain or service error into an API error.
func mapDomainError(err error) *APIError {
var ve *ingest.ValidationError
if errors.As(err, &ve) {
return &APIError{
Status: http.StatusBadRequest, Code: CodeValidationFailed,
Message: "The request could not be accepted.",
Fields: []FieldError{{Field: ve.Field, Message: ve.Message}},
}
}
var de *ingest.DuplicateError
if errors.As(err, &de) {
return &APIError{
Status: http.StatusConflict, Code: CodeDuplicate,
Message: "An item with identical content already exists. " +
"Send allow_duplicate=true to store it anyway.",
Detail: map[string]string{
"existing_item_id": de.ExistingID,
"existing_title": de.ExistingTitle,
},
}
}
switch {
case errors.Is(err, domain.ErrNotFound):
return &APIError{Status: http.StatusNotFound, Code: CodeNotFound,
Message: "The requested item does not exist."}
case errors.Is(err, domain.ErrForbidden):
return &APIError{Status: http.StatusForbidden, Code: CodeForbidden,
Message: "You do not have permission to perform this action."}
// A cross-workspace access attempt is reported as "not found" rather than
// "forbidden", so that a probe cannot confirm an ID exists elsewhere.
case errors.Is(err, domain.ErrWorkspaceMiss):
return &APIError{Status: http.StatusNotFound, Code: CodeNotFound,
Message: "The requested item does not exist."}
case errors.Is(err, domain.ErrDuplicate):
return &APIError{Status: http.StatusConflict, Code: CodeDuplicate,
Message: "That content already exists."}
case errors.Is(err, domain.ErrConflict):
return &APIError{Status: http.StatusConflict, Code: CodeConflict,
Message: "The record changed while your request was being processed. Please try again."}
case errors.Is(err, domain.ErrTooLarge):
return &APIError{Status: http.StatusRequestEntityTooLarge, Code: CodePayloadTooLarge,
Message: "The request body is larger than the configured limit."}
case errors.Is(err, domain.ErrUnsupported):
return &APIError{Status: http.StatusUnsupportedMediaType, Code: CodeUnsupportedMedia,
Message: "That content type is not supported."}
case errors.Is(err, domain.ErrInvalidInput):
return &APIError{Status: http.StatusBadRequest, Code: CodeInvalidRequest,
Message: firstLine(err.Error())}
case errors.Is(err, domain.ErrRateLimited), errors.Is(err, auth.ErrTooManyAttempts):
return &APIError{Status: http.StatusTooManyRequests, Code: CodeRateLimited,
Message: "Too many attempts. Please wait and try again."}
case errors.Is(err, auth.ErrNoSession), errors.Is(err, auth.ErrInvalidCredentials):
return &APIError{Status: http.StatusUnauthorized, Code: CodeUnauthorised,
Message: "Authentication is required."}
case errors.Is(err, analysis.ErrInvalidOutput):
return &APIError{Status: http.StatusBadGateway, Code: CodeModelInvalid,
Message: "The language model returned a response Relata could not validate. Nothing was stored."}
case errors.Is(err, analysis.ErrDisabled), errors.Is(err, search.ErrAnswerDisabled):
return &APIError{Status: http.StatusServiceUnavailable, Code: CodeFeatureDisabled,
Message: "This feature requires a language model, which is not configured."}
case errors.Is(err, domain.ErrUnavailable):
return &APIError{Status: http.StatusServiceUnavailable, Code: CodeDependencyDown,
Message: "A required service is unavailable. Please try again shortly."}
// A provider that ran out of time is a dependency problem, not an
// unexplained internal failure. Reporting it as one leaves a user with no
// idea that the fix is a bigger timeout or a smaller model.
case errors.Is(err, context.DeadlineExceeded):
return &APIError{Status: http.StatusGatewayTimeout, Code: CodeDependencyTimeout,
Message: "A language model or storage call took longer than the configured timeout. " +
"Try a smaller request, or raise RELATA_ANALYSIS_TIMEOUT and RELATA_HTTP_WRITE_TIMEOUT."}
case errors.Is(err, context.Canceled):
return &APIError{Status: 499, Code: CodeClientClosed,
Message: "The request was cancelled before it completed."}
}
return &APIError{
Status: http.StatusInternalServerError, Code: CodeInternal,
Message: "An unexpected error occurred. The request reference below can be quoted when reporting it.",
Internal: err,
}
}
// WriteJSON sends a successful JSON response.
//
// The response is encoded into a buffer first, so that a marshalling failure is
// reported as a 500 rather than truncating a body whose 200 status has already
// been written.
func WriteJSON(w http.ResponseWriter, status int, v any) {
h := w.Header()
h.Set("Content-Type", "application/json; charset=utf-8")
h.Set("X-Content-Type-Options", "nosniff")
if v == nil {
w.WriteHeader(status)
return
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(v); err != nil {
slog.Default().Error("could not encode response body", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"The response could not be encoded."}}`))
return
}
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
// DecodeJSON reads a JSON request body strictly.
//
// Unknown fields are rejected so a client sending a misspelled or unsupported
// field is told, rather than having it silently ignored; trailing content is
// rejected so two concatenated documents cannot be smuggled through; and the
// body is size-limited before parsing.
func DecodeJSON(w http.ResponseWriter, r *http.Request, maxBytes int64, dst any) error {
ct := r.Header.Get("Content-Type")
if ct != "" {
base, _, _ := strings.Cut(ct, ";")
if strings.TrimSpace(base) != "application/json" {
return &APIError{
Status: http.StatusUnsupportedMediaType, Code: CodeUnsupportedMedia,
Message: "The request body must be application/json.",
}
}
}
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return decodeError(err, maxBytes)
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
return BadRequest("The request body must contain exactly one JSON object.")
}
return nil
}
// decodeError turns a JSON decoding failure into a specific, safe message.
func decodeError(err error, maxBytes int64) error {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
var maxErr *http.MaxBytesError
switch {
case errors.As(err, &syntaxErr):
return BadRequest(fmt.Sprintf("The request body is not valid JSON (at position %d).", syntaxErr.Offset))
case errors.Is(err, io.ErrUnexpectedEOF):
return BadRequest("The request body ended unexpectedly and is not valid JSON.")
case errors.As(err, &typeErr):
return BadRequest("A field has the wrong type.", FieldError{
Field: typeErr.Field,
Message: fmt.Sprintf("expected %s", typeErr.Type.String()),
})
case errors.Is(err, io.EOF):
return BadRequest("The request body is empty.")
case errors.As(err, &maxErr):
return &APIError{
Status: http.StatusRequestEntityTooLarge, Code: CodePayloadTooLarge,
Message: fmt.Sprintf("The request body must be no larger than %d bytes.", maxBytes),
}
case strings.HasPrefix(err.Error(), "json: unknown field "):
field := strings.Trim(strings.TrimPrefix(err.Error(), "json: unknown field "), `"`)
return BadRequest("The request contains a field Relata does not accept.", FieldError{
Field: field,
Message: "unknown field",
})
}
return BadRequest("The request body could not be read.")
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if len(s) > 300 {
s = s[:300]
}
return s
}