- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
197 lines
9.5 KiB
Go
197 lines
9.5 KiB
Go
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)
|
||
})
|
||
}
|
||
}
|