package http import ( "encoding/json" "errors" "log/slog" "net/http" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) // Машиночитаемые коды отказа. Перечень закрыт и объявлен одним местом: код // HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все // три `400`, — а приложению надо решать, предлагать ли повтор и что показать // человеку. Разбор русской фразы был бы единственным оставшимся путём. // // Кода `forbidden` в перечне больше нет: его единственным случаем был владелец // панели, предъявивший собственный токен хранилища. Ни панели, ни токенов у // сервиса не осталось, а узнавание по заголовку учётную запись заводит само — // предъявителя без неё не бывает. const ( CodeUnauthorized = "unauthorized" CodeNotFound = "not_found" CodeBadRequest = "bad_request" CodeTooLarge = "too_large" CodeNotReady = "not_ready" CodeTooManyRequests = "too_many_requests" CodeInternal = "internal" ) // ErrorBody — единая форма тела отказа на всех адресах приложения. // // Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой // текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена // внешних сервисов, пути на диске, имена файлов. Полная ошибка остаётся в // журнале владельца сервиса. // // Limit заполняется только у отказа по размеру: экран обязан показать предел // числом, а не пересказать его словами. type ErrorBody struct { Code string `json:"error_code"` Message string `json:"message"` Limit *int64 `json:"limit,omitempty"` } // mapDomainError — **единственная** точка, где доменная ошибка становится кодом // ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик // решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя // ошибка» на негодный файл. // // Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится // добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в // `internal`, и владелец сервиса видит в журнале аварию там, где её нет. func mapDomainError(err error) (int, ErrorBody) { switch { case errors.Is(err, contract.ErrBadRequest): // Причина у всех негодных вводов одна, а сказать человеку надо разное: // «размер страницы отрицательный» и «неизвестный вид текста» ведут к // разным действиям. Свой текст приезжает обёрткой; его нет — говорим // общее. Сырой `err.Error()` наружу при этом не идёт: сообщение пишем мы, // а не библиотека. message := "Запрос составлен неверно" var owned *messagedError if errors.As(err, &owned) { message = owned.message } return http.StatusBadRequest, ErrorBody{Code: CodeBadRequest, Message: message} case errors.Is(err, contract.ErrRecordUnreadable): return http.StatusBadRequest, ErrorBody{ Code: CodeBadRequest, Message: "Не удалось прочитать запись: формат не распознан или файл повреждён", } case errors.Is(err, contract.ErrRecordTooLarge): limit := entity.MaxRecordSize return http.StatusRequestEntityTooLarge, ErrorBody{ Code: CodeTooLarge, Message: "Запись больше допустимого размера", Limit: &limit, } case errors.Is(err, contract.ErrTextNotReady): return http.StatusConflict, ErrorBody{ Code: CodeNotReady, Message: "Текст этого вида для записи ещё не готов", } case errors.Is(err, contract.ErrCopyNotReady): return http.StatusConflict, ErrorBody{ Code: CodeNotReady, Message: "Этой копии записи ещё нет", } case errors.Is(err, contract.ErrTooManyRequests): return http.StatusTooManyRequests, ErrorBody{ Code: CodeTooManyRequests, Message: "Слишком много запросов подряд, попробуйте позже", } case errors.Is(err, contract.ErrNotFound): message := "Адрес не найден" var owned *messagedError if errors.As(err, &owned) { message = owned.message } return http.StatusNotFound, ErrorBody{Code: CodeNotFound, Message: message} case errors.Is(err, contract.ErrUnauthorized): // Не «требуется вход»: своего входа у сервиса нет, и уводить человека // некуда. Сообщение называет то, что произошло на самом деле, — сервис // не узнал пришедшего, — и приложение показывает его как есть, своего // словаря текстов под коды ответа не заводя. return http.StatusUnauthorized, ErrorBody{ Code: CodeUnauthorized, Message: "Сервис вас не узнал", } } // Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице // ответов иначе перебирается список заведённых записей. var notFound *contract.JobNotFoundError if errors.As(err, ¬Found) { return http.StatusNotFound, ErrorBody{ Code: CodeNotFound, Message: "Запись не найдена", } } return http.StatusInternalServerError, ErrorBody{ Code: CodeInternal, Message: "Внутренняя ошибка сервиса", } } // fail отвечает отказом по доменной ошибке — единственный способ, которым отказ // уходит наружу с адресов приложения. // // Отказы, рождающиеся **не в обработчике** — предел тела, ограничитель частоты, // неизвестный путь под корнем приложения, — приходят сюда же: слои сервиса // написаны нами и отвечают своей доменной ошибкой, а не телом библиотеки. Второй // формы тела на адресах приложения не существует. func fail(w http.ResponseWriter, err error) { status, body := mapDomainError(err) writeJSON(w, status, body) } // writeJSON отдаёт тело ответа. Отказ записи в журнал не идёт: соединение к // этому моменту оборвано, и сказать о нём некому — строка о каждом закрытом // браузере наполняла бы журнал ничем. func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } // errWithMessage приклеивает к признаку негодного ввода свой текст: причина у // всех одна, а сказать человеку надо разное. func errWithMessage(base error, message string) error { return &messagedError{base: base, message: message} } type messagedError struct { base error message string } func (e *messagedError) Error() string { return e.message } func (e *messagedError) Unwrap() error { return e.base } // Recover — верхняя граница обработчика: паникующий запрос отдаёт `500` нашей // формой тела, а процесс живёт. // // Слой свой, потому что своим стал и роутер: прежде его вешала чужая библиотека. // У воркеров такой границы по-прежнему нет — паника в шаге конвейера роняет // процесс целиком, и это осознанно. func Recover(logger *slog.Logger) func(http.Handler) http.Handler { if logger == nil { logger = slog.Default() } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if recovered := recover(); recovered != nil { logger.Error("Handler panicked", "error", recovered, "transport", "http") fail(w, errors.New("handler panicked")) } }() next.ServeHTTP(w, r) }) } }