Конвенция для обработки ошибок + рефакторинг кода
This commit is contained in:
@@ -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`,
|
||||||
|
трансляция на внешней границе.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 пишет структурированный лог по каждому запросу. Частые
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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, "")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
@@ -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, ":")
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrConflict — операция недопустима в текущем состоянии загрузки (напр. apply
|
||||||
|
// вне review/deferred, undo вне done). Это нормальный конфликт состояния, а не
|
||||||
|
// сбой сервера: транспорт матчит его через errors.Is и отвечает 409, не 500.
|
||||||
|
var ErrConflict = errors.New("conflict")
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user