Files
glchat/internal/server/errors.go
T
grendervill e4bbc27a7c fix(realtime): объединение частых синхронизаций и лог неожиданных ошибок
- клиент откладывает перечитывание комнат (окно 250 мс) и не запускает второй
  запрос, пока идёт первый: серия событий ролей и участников больше не даёт
  лавину GET members/roles и 429 на лимите API;
- неожиданные ошибки API пишутся в лог (и в chi-, и в huma-ветке): под
  параллельной нагрузкой на стенде видели разовые 500 на GET members/roles,
  но без текста ошибки разобраться было нельзя.
2026-09-22 20:58:49 +03:00

125 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"glchat/internal/auth"
"glchat/internal/httpx"
"glchat/internal/permissions"
"glchat/internal/store"
)
// apiError — доменная ошибка с кодом для клиента (AGENT.md 8.5).
type apiError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
cause error
}
func (e apiError) Error() string { return e.Code + ": " + e.Message }
func (e apiError) Unwrap() error { return e.cause }
// newAPIError подбирает код и статус по доменной ошибке.
func newAPIError(err error) apiError {
candidate := apiError{Status: http.StatusInternalServerError, Code: "internal.error", Message: "internal error", cause: err}
switch {
case errors.Is(err, auth.ErrInvalidCredentials):
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.invalid_credentials", "invalid credentials"
case errors.Is(err, auth.ErrTOTPRequired):
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.2fa_required", "two-factor code required"
case errors.Is(err, auth.ErrTOTPInvalid):
candidate.Status, candidate.Code, candidate.Message = http.StatusBadRequest, "auth.totp_invalid", "invalid two-factor code"
case errors.Is(err, auth.ErrInstanceAdminTOTP):
candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_enrollment_required"
candidate.Message = "instance administrator must enable two-factor authentication: run `glchat totp-setup --email <admin>` on the server"
case errors.Is(err, auth.ErrUserBanned):
candidate.Status, candidate.Code = http.StatusForbidden, "user.banned"
candidate.Message = "account is banned on this instance"
case errors.Is(err, auth.ErrSessionExpired):
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired"
case errors.Is(err, auth.ErrStepUpRequired):
candidate.Status, candidate.Code = http.StatusForbidden, "auth.step_up_required"
candidate.Message = "step-up authentication required"
case errors.Is(err, auth.ErrUsernameTaken):
candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "auth.username_taken", "username is already taken"
case errors.Is(err, auth.ErrEmailTaken):
candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "auth.email_taken", "email is already registered"
case errors.Is(err, auth.ErrRegistrationOff):
candidate.Status, candidate.Code = http.StatusForbidden, "auth.registration_disabled"
candidate.Message = "registration is disabled"
case errors.Is(err, auth.ErrWeakPassword):
candidate.Status, candidate.Code, candidate.Message = http.StatusUnprocessableEntity, "auth.weak_password", err.Error()
case errors.Is(err, auth.ErrInvalidUsername):
candidate.Status, candidate.Code, candidate.Message = http.StatusUnprocessableEntity, "auth.invalid_username", err.Error()
case errors.Is(err, auth.ErrTOTPAlreadyEnabled):
candidate.Status, candidate.Code = http.StatusConflict, "auth.2fa_already_enabled"
candidate.Message = "two-factor authentication is already enabled"
case errors.Is(err, auth.ErrNoTOTPSecret):
candidate.Status, candidate.Code = http.StatusBadRequest, "auth.2fa_not_configured"
candidate.Message = "two-factor authentication is not configured"
case errors.Is(err, store.ErrNotFound):
candidate.Status, candidate.Code, candidate.Message = http.StatusNotFound, "not_found", "resource not found"
case errors.Is(err, store.ErrConflict):
candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "conflict", "resource already exists"
case errors.Is(err, permissions.ErrDenied):
candidate.Status, candidate.Code, candidate.Message = http.StatusForbidden, "perm.denied", "permission denied"
}
return candidate
}
// writeAPIError отдаёт ошибку в едином формате с машиночитаемым кодом.
func writeAPIError(w http.ResponseWriter, err error) {
apiErr := newAPIError(err)
if apiErr.Status >= http.StatusInternalServerError {
// Неожиданную ошибку пишем в лог: клиент видит «internal error», а
// разобраться без текста ошибки невозможно (AGENT.md 9.4).
slog.Error("unexpected API error", slog.String("code", apiErr.Code), slog.Any("error", err))
apiErr.Message = "internal error"
}
httpx.WriteJSON(w, apiErr.Status, map[string]any{
"error": map[string]any{
"code": apiErr.Code,
"message": apiErr.Message,
},
})
}
// writeHumaAPIError отдаёт ошибку chi-ручки: huma-ошибки уже несут код и
// статус, доменные ошибки переводит writeAPIError (AGENT.md 8.5).
func writeHumaAPIError(w http.ResponseWriter, err error) {
var apiErr *humaAPIError
if errors.As(err, &apiErr) {
httpx.WriteJSON(w, apiErr.status, map[string]any{
"error": map[string]any{"code": apiErr.code, "message": apiErr.message},
})
return
}
writeAPIError(w, err)
}
// writeJSON пишет успешный ответ (все текущие ручки возвращают 200).
func writeJSON(w http.ResponseWriter, body any) {
httpx.WriteJSON(w, http.StatusOK, body)
}
// decodeBody читает JSON-тело с ограничением размера.
func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool {
if r.Body == nil {
writeAPIError(w, errors.New("empty request body"))
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]any{
"error": map[string]any{"code": "request.bad", "message": "malformed json body"},
})
return false
}
return true
}