Конвенция для обработки ошибок + рефакторинг кода

This commit is contained in:
av
2026-06-28 21:22:12 +03:00
parent c6daba46d9
commit 3d5df62d62
14 changed files with 265 additions and 51 deletions
+4
View File
@@ -12,3 +12,7 @@
## Записи ## Записи
- [logging.md](logging.md) — логирование: уровни, поля, что не логируем. - [logging.md](logging.md) — логирование: уровни, поля, что не логируем.
- [config.md](config.md) — конфигурация: TOML, секреты через деплой
(Ansible+Vault), валидация на старте.
- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is`/`As`,
трансляция на внешней границе.
+92
View File
@@ -0,0 +1,92 @@
# Ошибки
Конвенция: *как* устроены и передаются ошибки в jellybit. Правила оформления
кода (How). Где и когда ошибку **логировать** — в [logging.md](logging.md),
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как
ошибки строятся, оборачиваются и проверяются.
## Базовая идиома: stdlib
- Только стандартный `errors` + `fmt.Errorf`. Без `pkg/errors` (в режиме
поддержки) и `cockroachdb/errors` (стек-трейсы/Sentry — избыточно для
домашнего сервиса). Контекст ошибки несёт `slog`, а не стек.
- Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал
пересмотреть, а не дефолт.
## Обёртка и контекст
jellybit — **приложение, а не библиотека**: внешнего Go-API нет, весь код
наш. Поэтому внутри приложения обёртка `%w`**дефолт**, чтобы `errors.Is`/
`errors.As` работали сквозь слои.
- Добавляем контекст обёрткой: `fmt.Errorf("parse magnet: %w", err)`.
- `%w` — когда вызывающий может инспектировать причину (наш обычный случай).
`%v` — когда причину сознательно **не** раскрываем (не хотим завязывать
вызывающего на чужой тип ошибки).
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в цепочке,
а трансляцией на внешней границе (см. ниже).
Стиль сообщения:
- со строчной, без точки в конце, без «failed to»/«error» — обёртка и так
читается как «контекст: причина»;
- контекст — операция/субъект: `"link target: %w"`, не `"something failed"`;
- без заикания: каждый слой добавляет **свой** смысл, не повторяет нижний
(`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`).
## Проверка ошибок
- Сравнение — только `errors.Is(err, ErrX)` (не `err == ErrX`) и
`errors.As(err, &target)`. **Никогда** не матчим по тексту
(`strings.Contains(err.Error(), …)`).
- Граничные ошибки зависимостей **транслируем в доменные у источника**:
`sql.ErrNoRows` → доменный `store.ErrNotFound` в слое store, чтобы выше по
коду не торчал `database/sql`.
## Sentinel vs типизированные
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий,
на которые ветвится код (нет записи, дубликат по infohash,
неподдерживаемый источник). Проверяем `errors.Is`.
- **Типизированная ошибка** (тип с полями + метод `Error()`) — когда
вызывающему нужны **данные** ошибки (поле валидации, код). Достаём
`errors.As`. Не плодим типы там, где хватает sentinel.
## Граница и трансляция: приватный vs публичный канал
Внутри — богатые обёрнутые ошибки. На внешней границе ошибку **транслируем**,
и форма зависит от канала, кто его видит:
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей
цепочкой `%w` и контекстом. Пишется один раз на доменной границе — см.
[logging.md](logging.md).
- **Публичный канал — пользовательские поверхности** (Telegram, web-UI, HTTP
API; ими пользуется не только владелец). Сюда отдаём:
- **человекочитаемое сообщение** по доменной ошибке — не сырой
`err.Error()` и не детали реализации (`database/sql`, пути, стек);
- **+ корреляционный ключ** для владельца — `download_id` (если операция
к загрузке) либо `request_id`, чтобы по нему найти полную ошибку в логах.
Пример: «При обработке загрузки произошла ошибка, download_id=12345», а
не «произошла ошибка» и не сырой текст;
- **маппинг доменной ошибки → статус/сообщение**: `ErrNotFound` → 404
«не найдено», валидация/`ErrNotMagnet` → 400 «некорректный источник»,
конфликт состояния (`ErrConflict` — операция недопустима в текущем
состоянии) → 409 «действие недоступно в текущем состоянии», прочее →
500 «внутренняя ошибка».
Граница публичная по умолчанию. Истинно приватный для владельца канал —
логи; отдельной «операторской» поверхности с сырыми ошибками не заводим.
## panic
- `panic` — только для невосстановимого: баг программиста (нарушенный
инвариант), ошибка инициализации, из которой нельзя стартовать.
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой
ввод) — это значения `error`.
- `recover` — на верхней границе обработчика (HTTP middleware), чтобы один
паникующий запрос не ронял процесс.
## Несколько ошибок
- Сбор независимых ошибок (напр. валидация конфига — все проблемы разом) —
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
+18 -14
View File
@@ -205,18 +205,22 @@ func Load(path string) (*Config, error) {
// включённых секций не пусты. Длительности уже провалидированы при разборе // включённых секций не пусты. Длительности уже провалидированы при разборе
// TOML (UnmarshalText). Лог об ошибке пишет граница (cmd/jellybit), не загрузчик. // TOML (UnmarshalText). Лог об ошибке пишет граница (cmd/jellybit), не загрузчик.
func (c *Config) validate() error { func (c *Config) validate() error {
// Собираем все проблемы разом (errors.Join), чтобы оператор увидел все
// огрехи отрендеренного файла за один проход, а не правил их по одной.
var errs []error
// Обязательные поля ядра. // Обязательные поля ядра.
if c.QBittorrent.URL == "" { if c.QBittorrent.URL == "" {
return errors.New("qbittorrent.url is empty") errs = append(errs, errors.New("qbittorrent.url is empty"))
} }
if c.HTTP.Listen == "" { if c.HTTP.Listen == "" {
return errors.New("http.listen is empty") errs = append(errs, errors.New("http.listen is empty"))
} }
if c.Storage.DBPath == "" { if c.Storage.DBPath == "" {
return errors.New("storage.db_path is empty") errs = append(errs, errors.New("storage.db_path is empty"))
} }
if c.LLM.Type != "openai-compat" { if c.LLM.Type != "openai-compat" {
return fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type) errs = append(errs, fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type))
} }
// Медиа-пути песочницы: абсолютные, без traversal, существующие каталоги. // Медиа-пути песочницы: абсолютные, без traversal, существующие каталоги.
@@ -226,45 +230,45 @@ func (c *Config) validate() error {
{"paths.series", c.Paths.Series}, {"paths.series", c.Paths.Series},
} { } {
if err := validateMediaDir(p.name, p.path); err != nil { if err := validateMediaDir(p.name, p.path); err != nil {
return err errs = append(errs, err)
} }
} }
// Диапазоны. // Диапазоны.
if t := c.Recognition.AutoConfidenceThreshold; t < 0 || t > 1 { if t := c.Recognition.AutoConfidenceThreshold; t < 0 || t > 1 {
return fmt.Errorf("recognition.auto_confidence_threshold %.3f is out of range [0, 1]", t) errs = append(errs, fmt.Errorf("recognition.auto_confidence_threshold %.3f is out of range [0, 1]", t))
} }
if c.LLM.MaxRetries < 0 { if c.LLM.MaxRetries < 0 {
return fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries) errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries))
} }
// Обязательные секреты включённых секций (ловит криво отрендеренный деплоем // Обязательные секреты включённых секций (ловит криво отрендеренный деплоем
// файл). qBittorrent — ядро, пароль нужен всегда. // файл). qBittorrent — ядро, пароль нужен всегда.
if c.QBittorrent.Password == "" { if c.QBittorrent.Password == "" {
return errors.New("qbittorrent.password is empty (required secret)") errs = append(errs, errors.New("qbittorrent.password is empty (required secret)"))
} }
// llm.api_key намеренно не обязателен: keyless-local LLM (LM Studio с // llm.api_key намеренно не обязателен: keyless-local LLM (LM Studio с
// заданным base_url, но без ключа) — валидный документированный дефолт. // заданным base_url, но без ключа) — валидный документированный дефолт.
// Консистентность опциональных секций: enabled ⇒ заданы нужные поля/секреты. // Консистентность опциональных секций: enabled ⇒ заданы нужные поля/секреты.
if c.Metadata.TMDB.Enabled && c.Metadata.TMDB.APIKey == "" { if c.Metadata.TMDB.Enabled && c.Metadata.TMDB.APIKey == "" {
return errors.New("metadata.tmdb.enabled but metadata.tmdb.api_key is empty") errs = append(errs, errors.New("metadata.tmdb.enabled but metadata.tmdb.api_key is empty"))
} }
if c.Metadata.TVDB.Enabled && c.Metadata.TVDB.APIKey == "" { if c.Metadata.TVDB.Enabled && c.Metadata.TVDB.APIKey == "" {
return errors.New("metadata.tvdb.enabled but metadata.tvdb.api_key is empty") errs = append(errs, errors.New("metadata.tvdb.enabled but metadata.tvdb.api_key is empty"))
} }
if c.Jellyfin.Enabled { if c.Jellyfin.Enabled {
if c.Jellyfin.URL == "" { if c.Jellyfin.URL == "" {
return errors.New("jellyfin.enabled but jellyfin.url is empty") errs = append(errs, errors.New("jellyfin.enabled but jellyfin.url is empty"))
} }
if c.Jellyfin.APIKey == "" { if c.Jellyfin.APIKey == "" {
return errors.New("jellyfin.enabled but jellyfin.api_key is empty (required secret)") errs = append(errs, errors.New("jellyfin.enabled but jellyfin.api_key is empty (required secret)"))
} }
} }
if c.Telegram.Enabled && c.Telegram.Token == "" { if c.Telegram.Enabled && c.Telegram.Token == "" {
return errors.New("telegram.enabled but telegram.token is empty (required secret)") errs = append(errs, errors.New("telegram.enabled but telegram.token is empty (required secret)"))
} }
return nil return errors.Join(errs...)
} }
// validateMediaDir проверяет путь медиа-песочницы: непустой, абсолютный, без // validateMediaDir проверяет путь медиа-песочницы: непустой, абсолютный, без
+67 -14
View File
@@ -8,6 +8,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -20,7 +21,9 @@ import (
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"git.vakhrushev.me/av/jellybit/internal/ingest" "git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/magnet"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
"git.vakhrushev.me/av/jellybit/web" "git.vakhrushev.me/av/jellybit/web"
) )
@@ -154,12 +157,12 @@ func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
redirectErr(w, r, "не удалось разобрать форму") redirectErr(w, r, "не удалось разобрать форму")
return return
} }
_, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{ res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{
Source: r.PostForm.Get("source"), Source: r.PostForm.Get("source"),
Context: r.PostForm.Get("context"), Context: r.PostForm.Get("context"),
}) })
if err != nil { if err != nil {
redirectErr(w, r, err.Error()) redirectErr(w, r, userErr(r, err, res.DownloadID))
return return
} }
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -172,7 +175,7 @@ func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.deps.Commander.Cancel(r.Context(), id); err != nil { if err := s.deps.Commander.Cancel(r.Context(), id); err != nil {
redirectErr(w, r, err.Error()) redirectErr(w, r, userErr(r, err, id))
return return
} }
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -207,7 +210,7 @@ type addResponse struct {
func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) { func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
downloads, err := s.deps.Reader.ListDownloads(r.Context()) downloads, err := s.deps.Reader.ListDownloads(r.Context())
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, errJSON(err)) s.apiErr(w, r, err, 0)
return return
} }
out := make([]downloadDTO, 0, len(downloads)) out := make([]downloadDTO, 0, len(downloads))
@@ -220,12 +223,13 @@ func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) { func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { if err != nil {
writeJSON(w, http.StatusBadRequest, errJSON(err)) writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
return return
} }
d, err := s.deps.Reader.GetDownload(r.Context(), id) d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil { if err != nil {
writeJSON(w, http.StatusNotFound, errJSON(err)) // ErrNotFound → 404, реальный сбой БД → 500 (не маскируем под 404).
s.apiErr(w, r, err, id)
return return
} }
writeJSON(w, http.StatusOK, toDTO(*d)) writeJSON(w, http.StatusOK, toDTO(*d))
@@ -234,12 +238,15 @@ func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) { func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
var req addRequest var req addRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil { if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errJSON(err)) writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", 0))
return return
} }
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context}) res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
if err != nil { if err != nil {
writeJSON(w, http.StatusBadRequest, errJSON(err)) // res.DownloadID непуст, если сбой после создания задачи (напр. qbit) —
// тогда коррелируем по download_id, иначе (ранний разбор источника) по
// request_id.
s.apiErr(w, r, err, res.DownloadID)
return return
} }
status := http.StatusCreated status := http.StatusCreated
@@ -265,14 +272,14 @@ func (s *server) handleAPIRetry(w http.ResponseWriter, r *http.Request) {
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, int64) error) { func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, int64) error) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { if err != nil {
writeJSON(w, http.StatusBadRequest, errJSON(err)) writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
return return
} }
if err := cmd(r.Context(), id); err != nil { if err := cmd(r.Context(), id); err != nil {
// Тонкий транспорт: возвращённую use-case'ом/воркером ошибку переводим в // Тонкий транспорт: возвращённую use-case'ом/воркером ошибку переводим в
// HTTP-статус и не логируем повторно (доменный слой уже залогировал, а // статус+сообщение и не логируем повторно (доменный слой уже залогировал,
// невалидный ввод — это норма, разбирать команде нечего). // а невалидный ввод — норма, разбирать команде нечего).
writeJSON(w, http.StatusConflict, errJSON(err)) s.apiErr(w, r, err, id)
return return
} }
d, err := s.deps.Reader.GetDownload(r.Context(), id) d, err := s.deps.Reader.GetDownload(r.Context(), id)
@@ -339,8 +346,54 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
_ = json.NewEncoder(w).Encode(v) _ = json.NewEncoder(w).Encode(v)
} }
func errJSON(err error) map[string]string { // classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
return map[string]string{"error": err.Error()} // человекочитаемое сообщение публичного канала (без сырого err.Error() и
// деталей реализации): ErrNotFound → 404, валидация источника
// (magnet.ErrNotMagnet) → 400, конфликт состояния (worker.ErrConflict) → 409,
// прочее → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём
// только сообщение + корреляционный ключ.
func classifyErr(err error) (int, string) {
switch {
case errors.Is(err, store.ErrNotFound):
return http.StatusNotFound, "не найдено"
case errors.Is(err, magnet.ErrNotMagnet):
return http.StatusBadRequest, "некорректный источник"
case errors.Is(err, worker.ErrConflict):
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
return http.StatusConflict, "действие недоступно в текущем состоянии"
default:
return http.StatusInternalServerError, "внутренняя ошибка"
}
}
// errBody — тело ошибки REST API: нейтральное сообщение + корреляционный ключ
// для владельца (download_id, если операция привязана к загрузке, иначе
// request_id запроса), по которому он найдёт полную ошибку в логах.
func errBody(r *http.Request, msg string, downloadID int64) map[string]any {
body := map[string]any{"error": msg}
if downloadID > 0 {
body["download_id"] = downloadID
} else {
body["request_id"] = middleware.GetReqID(r.Context())
}
return body
}
// apiErr пишет ответ об ошибке REST API по доменной ошибке (статус + тело).
func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID int64) {
status, msg := classifyErr(err)
writeJSON(w, status, errBody(r, msg, downloadID))
}
// userErr — сообщение публичного канала для веб-UI: нейтральный текст по
// доменной ошибке + корреляционный ключ владельцу (download_id, если операция
// привязана к загрузке, иначе request_id). Сырой текст ошибки наружу не идёт.
func userErr(r *http.Request, err error, downloadID int64) string {
_, msg := classifyErr(err)
if downloadID > 0 {
return fmt.Sprintf("%s (download_id=%d)", msg, downloadID)
}
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
} }
// requestLogger пишет структурированный лог по каждому запросу. Частые // requestLogger пишет структурированный лог по каждому запросу. Частые
+25 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -14,6 +15,7 @@ import (
"git.vakhrushev.me/av/jellybit/internal/httpapi" "git.vakhrushev.me/av/jellybit/internal/httpapi"
"git.vakhrushev.me/av/jellybit/internal/ingest" "git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/magnet"
"git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/internal/worker"
@@ -104,7 +106,9 @@ func TestAPIAdd(t *testing.T) {
} }
func TestAPIAddBadInput(t *testing.T) { func TestAPIAddBadInput(t *testing.T) {
ing := &fakeIngestor{err: ingestErr("bad magnet")} // Источник не magnet → ingest оборачивает magnet.ErrNotMagnet; транспорт
// классифицирует это как 400 (некорректный источник).
ing := &fakeIngestor{err: fmt.Errorf("ingest: parse source: %w", magnet.ErrNotMagnet)}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}}) srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`)) resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`))
@@ -156,6 +160,26 @@ func TestAPICancel(t *testing.T) {
} }
} }
func TestAPICommandConflict(t *testing.T) {
// Конфликт состояния (worker.ErrConflict) → 409, не 500.
cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads/5/cancel", "", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want 409", resp.StatusCode)
}
var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got)
if got["download_id"].(float64) != 5 {
t.Errorf("download_id корреляции нет: %v", got)
}
}
func TestIndexRenders(t *testing.T) { func TestIndexRenders(t *testing.T) {
reader := &fakeReader{list: []store.Download{ reader := &fakeReader{list: []store.Download{
{ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading}, {ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
+8 -8
View File
@@ -2,12 +2,12 @@ package httpapi
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/internal/worker"
) )
@@ -78,12 +78,12 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
} }
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, store.ErrNotFound) {
http.Error(w, "задача не найдена", http.StatusNotFound) http.Error(w, "задача не найдена", http.StatusNotFound)
return return
} }
s.deps.Logger.Error("review data", "id", id, "error", err) s.deps.Logger.Error("review data", "id", id, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return return
} }
@@ -155,7 +155,7 @@ func (s *server) handleApply(w http.ResponseWriter, r *http.Request) {
if err := s.deps.Reviewer.Apply(r.Context(), id); err != nil { if err := s.deps.Reviewer.Apply(r.Context(), id); err != nil {
// Тонкий транспорт: ошибку воркера переводим в ответ, не логируя // Тонкий транспорт: ошибку воркера переводим в ответ, не логируя
// повторно (доменный слой уже залогировал реальный сбой). // повторно (доменный слой уже залогировал реальный сбой).
redirectReview(w, r, id, err.Error()) redirectReview(w, r, id, userErr(r, err, id))
return return
} }
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -221,7 +221,7 @@ func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.deps.Reviewer.Defer(r.Context(), id); err != nil { if err := s.deps.Reviewer.Defer(r.Context(), id); err != nil {
redirectReview(w, r, id, err.Error()) redirectReview(w, r, id, userErr(r, err, id))
return return
} }
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -234,7 +234,7 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil { if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil {
redirectErr(w, r, err.Error()) redirectErr(w, r, userErr(r, err, id))
return return
} }
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -249,7 +249,7 @@ func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil { if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil {
redirectErr(w, r, err.Error()) redirectErr(w, r, userErr(r, err, id))
return return
} }
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -266,7 +266,7 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
if err := fn(r.Context(), id); err != nil { if err := fn(r.Context(), id); err != nil {
// Тонкий транспорт: ошибку переводим в ?err= на странице ревью, не // Тонкий транспорт: ошибку переводим в ?err= на странице ревью, не
// логируя повторно (доменный слой/валидация — не дело транспорта). // логируя повторно (доменный слой/валидация — не дело транспорта).
redirectReview(w, r, id, err.Error()) redirectReview(w, r, id, userErr(r, err, id))
return return
} }
redirectReview(w, r, id, "") redirectReview(w, r, id, "")
+1 -1
View File
@@ -78,7 +78,7 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
info, err := magnet.Parse(source) info, err := magnet.Parse(source)
if err != nil { if err != nil {
// Ф1: поддержан только magnet. .torrent/url — следующий заход. // Ф1: поддержан только magnet. .torrent/url — следующий заход.
return Result{}, fmt.Errorf("ingest: %w", err) return Result{}, fmt.Errorf("ingest: parse source: %w", err)
} }
// Scoped-логгер стадии приёма: download_id допишется после CreateDownload. // Scoped-логгер стадии приёма: download_id допишется после CreateDownload.
+1 -1
View File
@@ -25,7 +25,7 @@ func parsePlan(raw string, in Input) (Plan, error) {
// Повторяем без строгого режима: лишние поля — не повод падать, // Повторяем без строгого режима: лишние поля — не повод падать,
// но если и так не разобралось — это ошибка схемы. // но если и так не разобралось — это ошибка схемы.
if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil { if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil {
return Plan{}, fmt.Errorf("JSON not parsed: %v", err2) return Plan{}, fmt.Errorf("JSON not parsed: %w", err2)
} }
} }
+3
View File
@@ -102,6 +102,9 @@ VALUES (?, ?, ?, ?, ?, ?)`
func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) { func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
var d Download var d Download
if err := s.DB.GetContext(ctx, &d, `SELECT * FROM download WHERE id = ?`, id); err != nil { if err := s.DB.GetContext(ctx, &d, `SELECT * FROM download WHERE id = ?`, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get download %d: %w", id, ErrNotFound)
}
return nil, fmt.Errorf("get download %d: %w", id, err) return nil, fmt.Errorf("get download %d: %w", id, err)
} }
return &d, nil return &d, nil
+8
View File
@@ -0,0 +1,8 @@
package store
import "errors"
// ErrNotFound — доменный sentinel «запись не найдена». Слой store транслирует
// в него sql.ErrNoRows у источника, чтобы выше по коду не торчал database/sql,
// а потребители матчили причину через errors.Is(err, store.ErrNotFound).
var ErrNotFound = errors.New("not found")
+4 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
) )
@@ -91,7 +92,7 @@ func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*R
err := s.DB.GetContext(ctx, &r, err := s.DB.GetContext(ctx, &r,
`SELECT * FROM recognition WHERE download_id = ? AND is_current = 1 `SELECT * FROM recognition WHERE download_id = ? AND is_current = 1
ORDER BY attempt_no DESC LIMIT 1`, downloadID) ORDER BY attempt_no DESC LIMIT 1`, downloadID)
if err == sql.ErrNoRows { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
if err != nil { if err != nil {
@@ -201,7 +202,7 @@ func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, er
err := s.DB.GetContext(ctx, &batch, err := s.DB.GetContext(ctx, &batch,
`SELECT apply_batch_id FROM file_link WHERE download_id = ? `SELECT apply_batch_id FROM file_link WHERE download_id = ?
ORDER BY id DESC LIMIT 1`, downloadID) ORDER BY id DESC LIMIT 1`, downloadID)
if err == sql.ErrNoRows { if errors.Is(err, sql.ErrNoRows) {
return "", nil return "", nil
} }
if err != nil { if err != nil {
@@ -285,7 +286,7 @@ func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID i
func (s *Store) GetCandidate(ctx context.Context, id int64) (*MetadataCandidate, error) { func (s *Store) GetCandidate(ctx context.Context, id int64) (*MetadataCandidate, error) {
var c MetadataCandidate var c MetadataCandidate
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id) err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
if err == sql.ErrNoRows { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
if err != nil { if err != nil {
+17 -3
View File
@@ -125,7 +125,7 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
// Ждём подсказку для перераспознавания? // Ждём подсказку для перераспознавания?
if id, ok := b.takePending(m.Chat.ID); ok && !strings.Contains(text, "magnet:") { if id, ok := b.takePending(m.Chat.ID); ok && !strings.Contains(text, "magnet:") {
if err := b.reviewer.Refine(ctx, id, text); err != nil { if err := b.reviewer.Refine(ctx, id, text); err != nil {
b.send(m.Chat.ID, "Не удалось: "+err.Error(), nil) b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
return return
} }
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil) b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil)
@@ -144,7 +144,10 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
} }
res, err := b.ingestor.Ingest(ctx, ingest.Request{Source: source, Context: context}) res, err := b.ingestor.Ingest(ctx, ingest.Request{Source: source, Context: context})
if err != nil { if err != nil {
b.send(m.Chat.ID, "Ошибка приёма: "+err.Error(), nil) // res.DownloadID непуст, если сбой после создания задачи (напр. qbit);
// при раннем разборе источника id ещё нет — даём дружелюбный текст без
// него (детали всё равно в логах на доменной границе).
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
return return
} }
msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State) msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State)
@@ -203,7 +206,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
if err != nil { if err != nil {
b.answer(cq.ID, "Ошибка") b.answer(cq.ID, "Ошибка")
b.send(chatID, "Не удалось: "+err.Error(), nil) b.send(chatID, opErr("Не удалось выполнить действие", id), nil)
return return
} }
b.answer(cq.ID, note) b.answer(cq.ID, note)
@@ -286,6 +289,17 @@ func (b *Bot) takePending(chatID int64) (int64, bool) {
return id, ok return id, ok
} }
// opErr — сообщение публичного канала Telegram по доменной ошибке: нейтральный
// текст + download_id для корреляции с логами (полная ошибка уже там, на
// доменной границе). Сырой err.Error() пользователю не показываем. Если id
// операции ещё нет (downloadID == 0) — дружелюбный текст без ключа.
func opErr(msg string, downloadID int64) string {
if downloadID > 0 {
return fmt.Sprintf("%s (download_id=%d).", msg, downloadID)
}
return msg + "."
}
// parseCallback разбирает "action[:id[:value]]". // parseCallback разбирает "action[:id[:value]]".
func parseCallback(data string) (action string, id int64, value string) { func parseCallback(data string) (action string, id int64, value string) {
parts := strings.Split(data, ":") parts := strings.Split(data, ":")
+8
View File
@@ -0,0 +1,8 @@
package worker
import "errors"
// ErrConflict — операция недопустима в текущем состоянии загрузки (напр. apply
// вне review/deferred, undo вне done). Это нормальный конфликт состояния, а не
// сбой сервера: транспорт матчит его через errors.Is и отвечает 409, не 500.
var ErrConflict = errors.New("conflict")
+9 -6
View File
@@ -225,7 +225,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
return fmt.Errorf("apply: %w", err) return fmt.Errorf("apply: %w", err)
} }
if d.State != store.StateReview && d.State != store.StateDeferred { if d.State != store.StateReview && d.State != store.StateDeferred {
return fmt.Errorf("apply: download %d is in state %s (expected review/deferred)", id, d.State) return fmt.Errorf("apply: download %d is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
} }
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String) ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
@@ -234,8 +234,11 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
return fmt.Errorf("apply: %w", err) return fmt.Errorf("apply: %w", err)
} }
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String) t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
if err != nil || !ok { if err != nil {
return fmt.Errorf("apply: torrent not found: %v", err) return fmt.Errorf("apply: lookup torrent: %w", err)
}
if !ok {
return fmt.Errorf("apply: torrent not found")
} }
w.transition(ctx, *d, store.StateLinking, "", "") w.transition(ctx, *d, store.StateLinking, "", "")
@@ -304,7 +307,7 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
return fmt.Errorf("relink: %w", err) return fmt.Errorf("relink: %w", err)
} }
if d.State != store.StateReverted && d.State != store.StateCancelled { if d.State != store.StateReverted && d.State != store.StateCancelled {
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled)", id, d.State) return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled): %w", id, d.State, ErrConflict)
} }
if !d.Infohash.Valid { if !d.Infohash.Valid {
return fmt.Errorf("relink: download %d has no infohash", id) return fmt.Errorf("relink: download %d has no infohash", id)
@@ -462,7 +465,7 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
return fmt.Errorf("undo: %w", err) return fmt.Errorf("undo: %w", err)
} }
if d.State != store.StateDone { if d.State != store.StateDone {
return fmt.Errorf("undo: download %d is in state %s (expected done)", id, d.State) return fmt.Errorf("undo: download %d is in state %s (expected done): %w", id, d.State, ErrConflict)
} }
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String) ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
batch, err := w.store.LatestBatchID(ctx, id) batch, err := w.store.LatestBatchID(ctx, id)
@@ -499,7 +502,7 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
return nil, fmt.Errorf("%s: %w", op, err) return nil, fmt.Errorf("%s: %w", op, err)
} }
if d.State != store.StateReview && d.State != store.StateDeferred { if d.State != store.StateReview && d.State != store.StateDeferred {
return nil, fmt.Errorf("%s: download %d is in state %s (expected review/deferred)", op, id, d.State) return nil, fmt.Errorf("%s: download %d is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
} }
return d, nil return d, nil
} }