From 3d5df62d6254b56fb20eb19d0668ff1197b59abc Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 28 Jun 2026 21:22:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D0=B2=D0=B5=D0=BD=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=20+=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/README.md | 4 ++ docs/conventions/errors.md | 92 ++++++++++++++++++++++++++++++++ internal/config/config.go | 32 ++++++----- internal/httpapi/httpapi.go | 81 +++++++++++++++++++++++----- internal/httpapi/httpapi_test.go | 26 ++++++++- internal/httpapi/review.go | 16 +++--- internal/ingest/ingest.go | 2 +- internal/recognize/validate.go | 2 +- internal/store/download.go | 3 ++ internal/store/errors.go | 8 +++ internal/store/recognition.go | 7 +-- internal/tgbot/bot.go | 20 +++++-- internal/worker/errors.go | 8 +++ internal/worker/review.go | 15 +++--- 14 files changed, 265 insertions(+), 51 deletions(-) create mode 100644 docs/conventions/errors.md create mode 100644 internal/store/errors.go create mode 100644 internal/worker/errors.go diff --git a/docs/conventions/README.md b/docs/conventions/README.md index 8b7bc47..55ef010 100644 --- a/docs/conventions/README.md +++ b/docs/conventions/README.md @@ -12,3 +12,7 @@ ## Записи - [logging.md](logging.md) — логирование: уровни, поля, что не логируем. +- [config.md](config.md) — конфигурация: TOML, секреты через деплой + (Ansible+Vault), валидация на старте. +- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is`/`As`, + трансляция на внешней границе. diff --git a/docs/conventions/errors.md b/docs/conventions/errors.md new file mode 100644 index 0000000..c9298a8 --- /dev/null +++ b/docs/conventions/errors.md @@ -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`. diff --git a/internal/config/config.go b/internal/config/config.go index cbbe9de..3a72a55 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -205,18 +205,22 @@ func Load(path string) (*Config, error) { // включённых секций не пусты. Длительности уже провалидированы при разборе // TOML (UnmarshalText). Лог об ошибке пишет граница (cmd/jellybit), не загрузчик. func (c *Config) validate() error { + // Собираем все проблемы разом (errors.Join), чтобы оператор увидел все + // огрехи отрендеренного файла за один проход, а не правил их по одной. + var errs []error + // Обязательные поля ядра. if c.QBittorrent.URL == "" { - return errors.New("qbittorrent.url is empty") + errs = append(errs, errors.New("qbittorrent.url is empty")) } if c.HTTP.Listen == "" { - return errors.New("http.listen is empty") + errs = append(errs, errors.New("http.listen is empty")) } 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" { - 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, существующие каталоги. @@ -226,45 +230,45 @@ func (c *Config) validate() error { {"paths.series", c.Paths.Series}, } { if err := validateMediaDir(p.name, p.path); err != nil { - return err + errs = append(errs, err) } } // Диапазоны. 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 { - 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 — ядро, пароль нужен всегда. 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 с // заданным base_url, но без ключа) — валидный документированный дефолт. // Консистентность опциональных секций: enabled ⇒ заданы нужные поля/секреты. 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 == "" { - 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.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 == "" { - 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 == "" { - 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 проверяет путь медиа-песочницы: непустой, абсолютный, без diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 1b661c1..55f0005 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "html/template" "log/slog" "net/http" @@ -20,7 +21,9 @@ import ( "github.com/go-chi/chi/v5/middleware" "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/worker" "git.vakhrushev.me/av/jellybit/web" ) @@ -154,12 +157,12 @@ func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) { redirectErr(w, r, "не удалось разобрать форму") 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"), Context: r.PostForm.Get("context"), }) if err != nil { - redirectErr(w, r, err.Error()) + redirectErr(w, r, userErr(r, err, res.DownloadID)) return } http.Redirect(w, r, "/", http.StatusSeeOther) @@ -172,7 +175,7 @@ func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) { return } if err := s.deps.Commander.Cancel(r.Context(), id); err != nil { - redirectErr(w, r, err.Error()) + redirectErr(w, r, userErr(r, err, id)) return } http.Redirect(w, r, "/", http.StatusSeeOther) @@ -207,7 +210,7 @@ type addResponse struct { func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) { downloads, err := s.deps.Reader.ListDownloads(r.Context()) if err != nil { - writeJSON(w, http.StatusInternalServerError, errJSON(err)) + s.apiErr(w, r, err, 0) return } 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) { id, err := pathID(r) if err != nil { - writeJSON(w, http.StatusBadRequest, errJSON(err)) + writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0)) return } d, err := s.deps.Reader.GetDownload(r.Context(), id) if err != nil { - writeJSON(w, http.StatusNotFound, errJSON(err)) + // ErrNotFound → 404, реальный сбой БД → 500 (не маскируем под 404). + s.apiErr(w, r, err, id) return } 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) { var req addRequest 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 } res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context}) if err != nil { - writeJSON(w, http.StatusBadRequest, errJSON(err)) + // res.DownloadID непуст, если сбой после создания задачи (напр. qbit) — + // тогда коррелируем по download_id, иначе (ранний разбор источника) по + // request_id. + s.apiErr(w, r, err, res.DownloadID) return } 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) { id, err := pathID(r) if err != nil { - writeJSON(w, http.StatusBadRequest, errJSON(err)) + writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0)) return } if err := cmd(r.Context(), id); err != nil { // Тонкий транспорт: возвращённую use-case'ом/воркером ошибку переводим в - // HTTP-статус и не логируем повторно (доменный слой уже залогировал, а - // невалидный ввод — это норма, разбирать команде нечего). - writeJSON(w, http.StatusConflict, errJSON(err)) + // статус+сообщение и не логируем повторно (доменный слой уже залогировал, + // а невалидный ввод — норма, разбирать команде нечего). + s.apiErr(w, r, err, id) return } 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) } -func errJSON(err error) map[string]string { - return map[string]string{"error": err.Error()} +// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное +// человекочитаемое сообщение публичного канала (без сырого 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 пишет структурированный лог по каждому запросу. Частые diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index f17f822..4bbb3b1 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "io" "log/slog" "net/http" @@ -14,6 +15,7 @@ import ( "git.vakhrushev.me/av/jellybit/internal/httpapi" "git.vakhrushev.me/av/jellybit/internal/ingest" "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/store" "git.vakhrushev.me/av/jellybit/internal/worker" @@ -104,7 +106,9 @@ func TestAPIAdd(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{}}) 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) { reader := &fakeReader{list: []store.Download{ {ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading}, diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index 85af8a0..61b49ba 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -2,12 +2,12 @@ package httpapi import ( "context" - "database/sql" "errors" "net/http" "net/url" "strconv" + "git.vakhrushev.me/av/jellybit/internal/store" "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) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, store.ErrNotFound) { http.Error(w, "задача не найдена", http.StatusNotFound) return } s.deps.Logger.Error("review data", "id", id, "error", err) - http.Error(w, "internal error", http.StatusInternalServerError) + http.Error(w, "внутренняя ошибка", http.StatusInternalServerError) 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 { // Тонкий транспорт: ошибку воркера переводим в ответ, не логируя // повторно (доменный слой уже залогировал реальный сбой). - redirectReview(w, r, id, err.Error()) + redirectReview(w, r, id, userErr(r, err, id)) return } http.Redirect(w, r, "/", http.StatusSeeOther) @@ -221,7 +221,7 @@ func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) { return } 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 } http.Redirect(w, r, "/", http.StatusSeeOther) @@ -234,7 +234,7 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) { return } if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil { - redirectErr(w, r, err.Error()) + redirectErr(w, r, userErr(r, err, id)) return } http.Redirect(w, r, "/", http.StatusSeeOther) @@ -249,7 +249,7 @@ func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) { return } if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil { - redirectErr(w, r, err.Error()) + redirectErr(w, r, userErr(r, err, id)) return } 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 { // Тонкий транспорт: ошибку переводим в ?err= на странице ревью, не // логируя повторно (доменный слой/валидация — не дело транспорта). - redirectReview(w, r, id, err.Error()) + redirectReview(w, r, id, userErr(r, err, id)) return } redirectReview(w, r, id, "") diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index cffe13a..3cbcb64 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -78,7 +78,7 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { info, err := magnet.Parse(source) if err != nil { // Ф1: поддержан только magnet. .torrent/url — следующий заход. - return Result{}, fmt.Errorf("ingest: %w", err) + return Result{}, fmt.Errorf("ingest: parse source: %w", err) } // Scoped-логгер стадии приёма: download_id допишется после CreateDownload. diff --git a/internal/recognize/validate.go b/internal/recognize/validate.go index 6964b11..234b372 100644 --- a/internal/recognize/validate.go +++ b/internal/recognize/validate.go @@ -25,7 +25,7 @@ func parsePlan(raw string, in Input) (Plan, error) { // Повторяем без строгого режима: лишние поля — не повод падать, // но если и так не разобралось — это ошибка схемы. 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) } } diff --git a/internal/store/download.go b/internal/store/download.go index 2ab35f8..57721e7 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -102,6 +102,9 @@ VALUES (?, ?, ?, ?, ?, ?)` func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) { var d Download 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 &d, nil diff --git a/internal/store/errors.go b/internal/store/errors.go new file mode 100644 index 0000000..143c719 --- /dev/null +++ b/internal/store/errors.go @@ -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") diff --git a/internal/store/recognition.go b/internal/store/recognition.go index 4a2ac12..c6c4f94 100644 --- a/internal/store/recognition.go +++ b/internal/store/recognition.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" ) @@ -91,7 +92,7 @@ func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*R err := s.DB.GetContext(ctx, &r, `SELECT * FROM recognition WHERE download_id = ? AND is_current = 1 ORDER BY attempt_no DESC LIMIT 1`, downloadID) - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return nil, 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, `SELECT apply_batch_id FROM file_link WHERE download_id = ? ORDER BY id DESC LIMIT 1`, downloadID) - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return "", 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) { var c MetadataCandidate 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 } if err != nil { diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go index c57478d..7c03975 100644 --- a/internal/tgbot/bot.go +++ b/internal/tgbot/bot.go @@ -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 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 } 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}) 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 } 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 { b.answer(cq.ID, "Ошибка") - b.send(chatID, "Не удалось: "+err.Error(), nil) + b.send(chatID, opErr("Не удалось выполнить действие", id), nil) return } b.answer(cq.ID, note) @@ -286,6 +289,17 @@ func (b *Bot) takePending(chatID int64) (int64, bool) { 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]]". func parseCallback(data string) (action string, id int64, value string) { parts := strings.Split(data, ":") diff --git a/internal/worker/errors.go b/internal/worker/errors.go new file mode 100644 index 0000000..4e9d42b --- /dev/null +++ b/internal/worker/errors.go @@ -0,0 +1,8 @@ +package worker + +import "errors" + +// ErrConflict — операция недопустима в текущем состоянии загрузки (напр. apply +// вне review/deferred, undo вне done). Это нормальный конфликт состояния, а не +// сбой сервера: транспорт матчит его через errors.Is и отвечает 409, не 500. +var ErrConflict = errors.New("conflict") diff --git a/internal/worker/review.go b/internal/worker/review.go index 1e2b714..f7f4f9d 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -225,7 +225,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error { return fmt.Errorf("apply: %w", err) } 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) @@ -234,8 +234,11 @@ func (w *Worker) Apply(ctx context.Context, id int64) error { return fmt.Errorf("apply: %w", err) } t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String) - if err != nil || !ok { - return fmt.Errorf("apply: torrent not found: %v", err) + if err != nil { + return fmt.Errorf("apply: lookup torrent: %w", err) + } + if !ok { + return fmt.Errorf("apply: torrent not found") } 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) } 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 { 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) } 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) 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) } 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 }