Логирование: классификация доменных ошибок (500→409/400) + конвенции
Штатные конфликты и промахи ввода возвращались голым fmt.Errorf, поэтому
classifyErr отправлял их в 500 «внутренняя ошибка» вместо 409/400 (и logCmd
писал ERROR вместо DEBUG). Продолжение f8fb4fa (Tier A), по итогам ревью Fable.
Классификация ошибок:
- новый sentinel worker.ErrInvalidInput → 400 для валидации ввода команд
(refine/set type/ignore/add source/set provider/choose candidate);
- обёртки %w ErrConflict в Cancel/Retry/Defer/Undo (штатный конфликт состояния);
- classifyErr: ErrInvalidInput→400, layout.ErrCollision→409 (коллизия цели
штатно уводит в review); ветка ErrCollision в tgbot (сообщение + refreshCard);
- logCmd относит ErrInvalidInput и ErrCollision в DEBUG «command rejected».
Конвенции (docs/conventions):
- logging.md: публичные команды воркера = доменная граница (лог один раз,
logCmd); таблица уровней доменных отказов (граница команды vs асинхронная
стадия); правило про *url.Error/секреты в URL; канон категории
state transition; уровень повторяющихся сбоев фоновых циклов;
- errors.md: таблица маппинга ошибка→статус; развилка «транзиентный ответ vs
персистентная диагностика» решена как (а) — error_msg/reasons на review-экране
и tg-карточке = операторская поверхность владельца (сырой текст ок, секреты
запрещены; аудит подтвердил, что секреты туда не текут).
Унификация категории лога state transition: cancel/retry/relink/recovery
переведены с семантических msg на общий state transition (from/to) — весь
жизненный цикл собирается одним jq-фильтром.
Мелочи: reason-коды linkPlan в const-блок; httpapi лог-поля id→download_id и
msg «… failed»; комментарий «почему» у parseIgnored; preview build failure в
ReviewData DEBUG→WARN.
Беклог: задача сведена к остатку (ext.* ERROR-шторм при недоступном qBittorrent
+ эскалация устойчивого сбоя тика), понижена в приоритете.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,7 +81,7 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "задача не найдена", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.deps.Logger.Error("download detail data", "id", id, "error", err)
|
||||
s.deps.Logger.Error("download detail data failed", "download_id", id, "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"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/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/torrent"
|
||||
@@ -747,20 +748,29 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
|
||||
// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
|
||||
// человекочитаемое сообщение публичного канала (без сырого err.Error() и
|
||||
// деталей реализации): ErrNotFound → 404, валидация источника
|
||||
// (magnet.ErrNotMagnet) → 400, недокачанный источник (worker.ErrNotReady) и
|
||||
// конфликт состояния (worker.ErrConflict) → 409, прочее → 500. Полная ошибка
|
||||
// уже в логах на доменной границе — наружу отдаём только сообщение +
|
||||
// корреляционный ключ.
|
||||
// деталей реализации): ErrNotFound → 404; валидация источника
|
||||
// (magnet.ErrNotMagnet) и некорректный ввод команды (worker.ErrInvalidInput) →
|
||||
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
|
||||
// (layout.ErrCollision) и конфликт состояния (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), errors.Is(err, torrent.ErrNotTorrent):
|
||||
return http.StatusBadRequest, "некорректный источник"
|
||||
case errors.Is(err, worker.ErrInvalidInput):
|
||||
// Промах пользователя (пустая подсказка, неизвестный тип/провайдер, …),
|
||||
// не сбой сервера.
|
||||
return http.StatusBadRequest, "некорректный ввод"
|
||||
case errors.Is(err, worker.ErrNotReady):
|
||||
// Источник ещё качается — actionable причина, показываем конкретно.
|
||||
return http.StatusConflict, "торрент ещё качается, дождитесь докачки"
|
||||
case errors.Is(err, layout.ErrCollision):
|
||||
// Целевой путь уже занят: задача штатно ушла в review с причиной —
|
||||
// это не сбой, а требующий разбора конфликт.
|
||||
return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью"
|
||||
case errors.Is(err, worker.ErrConflict):
|
||||
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
||||
return http.StatusConflict, "действие недоступно в текущем состоянии"
|
||||
|
||||
@@ -247,6 +247,41 @@ func TestAPICommandNotReady(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPICommandInvalidInput(t *testing.T) {
|
||||
// Промах пользователя (worker.ErrInvalidInput) → 400, не 500.
|
||||
cmd := &fakeCommander{err: fmt.Errorf("set type: invalid type %q: %w", "foo", worker.ErrInvalidInput)}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPICommandCollision(t *testing.T) {
|
||||
// Коллизия цели (layout.ErrCollision) → 409 (штатно ушло в review), не 500.
|
||||
cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrCollision)}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/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 msg, _ := got["error"].(string); !strings.Contains(msg, "уже существует") {
|
||||
t.Errorf("error = %q, want содержащее «уже существует»", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexRenders(t *testing.T) {
|
||||
reader := &fakeReader{list: []store.Download{
|
||||
{ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
|
||||
|
||||
@@ -87,7 +87,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "задача не найдена", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.deps.Logger.Error("review data", "id", id, "error", err)
|
||||
s.deps.Logger.Error("review data failed", "download_id", id, "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -383,7 +383,7 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
|
||||
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
if err != nil {
|
||||
s.deps.Logger.Error("review data", "id", id, "error", err)
|
||||
s.deps.Logger.Error("review data failed", "download_id", id, "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -443,7 +443,7 @@ func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn fu
|
||||
// и рендерим свежий партиал блока.
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
if err != nil {
|
||||
s.deps.Logger.Error("review data", "id", id, "error", err)
|
||||
s.deps.Logger.Error("review data failed", "download_id", id, "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
@@ -345,6 +346,14 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, layout.ErrCollision) {
|
||||
// Коллизия цели: задача штатно ушла в review с причиной — показываем
|
||||
// конкретно и обновляем карточку (в ней теперь причина коллизии).
|
||||
b.answer(cq.ID, "Коллизия цели")
|
||||
b.send(chatID, opErr("Целевой файл уже существует — задача отправлена в ревью", id), nil)
|
||||
b.refreshCard(ctx, chatID, msgID, id)
|
||||
return
|
||||
}
|
||||
// Ошибку логирует доменная граница (соответствующая команда worker);
|
||||
// транспорт лишь переводит её в ответ пользователю (logging.md).
|
||||
b.answer(cq.ID, "Ошибка")
|
||||
|
||||
@@ -13,3 +13,10 @@ var ErrConflict = errors.New("conflict")
|
||||
// от ErrConflict (тоже 409), потому что причина actionable — «дождись докачки»
|
||||
// — и транспорт показывает её конкретным текстом, а не генериком конфликта.
|
||||
var ErrNotReady = errors.New("source not ready")
|
||||
|
||||
// ErrInvalidInput — команда отклонена из-за некорректного пользовательского
|
||||
// ввода (пустая подсказка, неизвестный тип/провайдер, пустой id, кандидат не из
|
||||
// текущей рекогниции). Это промах пользователя, а не сбой сервера: транспорт
|
||||
// матчит его через errors.Is и отвечает 400, а логирующая граница домена пишет
|
||||
// DEBUG (адресат — пользователь, он уже получил ответ на поверхности).
|
||||
var ErrInvalidInput = errors.New("invalid input")
|
||||
|
||||
@@ -208,7 +208,9 @@ func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byH
|
||||
logctx.From(ctx).Warn("recovery activate failed", "error", err)
|
||||
return
|
||||
}
|
||||
logctx.From(ctx).Info("recovery from failure", "from", d.State, "to", want, "qbit_state", t.State)
|
||||
// Восстановление из failed/stuck — тоже переход состояния: единый msg
|
||||
// `state transition` (from/to), qbit_state — отличительная деталь авто-воскрешения.
|
||||
logctx.From(ctx).Info("state transition", "from", d.State, "to", want, "qbit_state", t.State)
|
||||
}
|
||||
|
||||
// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому
|
||||
|
||||
+35
-21
@@ -286,7 +286,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
// живого якоря; рассинхрон (несколько разных живых папок) → review.
|
||||
folderBase, desync, err := w.resolveFolderBase(ctx, d.ID, provider, providerID, layout.MediaType(plan.Type))
|
||||
if err != nil {
|
||||
w.transition(ctx, *d, store.StateReview, "resolve", err.Error())
|
||||
w.transition(ctx, *d, store.StateReview, reasonResolve, err.Error())
|
||||
return fmt.Errorf("link plan: %w", err)
|
||||
}
|
||||
if desync {
|
||||
@@ -296,7 +296,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
|
||||
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag(provider, providerID), folderBase))
|
||||
if err != nil {
|
||||
w.transition(ctx, *d, store.StateReview, "build", err.Error())
|
||||
w.transition(ctx, *d, store.StateReview, reasonBuild, err.Error())
|
||||
return fmt.Errorf("build links: %w", err)
|
||||
}
|
||||
|
||||
@@ -323,7 +323,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
// файлы висели бы без file_link — MAJOR-4): уводим в review с
|
||||
// причиной. Повторный Apply идемпотентен — Apply вернёт StatusExists
|
||||
// на уже созданных ссылках и допишет учёт.
|
||||
w.transition(ctx, *d, store.StateReview, "persist", err.Error())
|
||||
w.transition(ctx, *d, store.StateReview, reasonPersist, err.Error())
|
||||
return fmt.Errorf("persist links: %w", err)
|
||||
}
|
||||
// Инвариант «один целевой путь — один владелец»: забираем владение
|
||||
@@ -346,7 +346,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
|
||||
if applyErr != nil {
|
||||
if errors.Is(applyErr, layout.ErrCollision) {
|
||||
w.transition(ctx, *d, store.StateReview, "collision", applyErr.Error())
|
||||
w.transition(ctx, *d, store.StateReview, reasonCollision, applyErr.Error())
|
||||
return applyErr
|
||||
}
|
||||
w.transition(ctx, *d, store.StateFailed, "apply", applyErr.Error())
|
||||
@@ -394,7 +394,7 @@ func (w *Worker) Relink(ctx context.Context, id string) (err error) {
|
||||
}
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
logctx.From(ctx).Info("relink re-recognizing", "from", d.State)
|
||||
logctx.From(ctx).Info("state transition", "from", d.State, "to", store.StateRecognizing)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -424,7 +424,7 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) (err error)
|
||||
defer func() { w.logCmd(ctx, "refine", id, err) }()
|
||||
hint = strings.TrimSpace(hint)
|
||||
if hint == "" {
|
||||
return fmt.Errorf("refine: empty hint")
|
||||
return fmt.Errorf("refine: empty hint: %w", ErrInvalidInput)
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -450,7 +450,7 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) (err error)
|
||||
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "set_type", id, err) }()
|
||||
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
|
||||
return fmt.Errorf("set type: invalid type %q", mediaType)
|
||||
return fmt.Errorf("set type: invalid type %q: %w", mediaType, ErrInvalidInput)
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -483,7 +483,7 @@ func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) (err err
|
||||
defer func() { w.logCmd(ctx, "ignore_file", id, err) }()
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return fmt.Errorf("ignore: empty path")
|
||||
return fmt.Errorf("ignore: empty path: %w", ErrInvalidInput)
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -519,7 +519,7 @@ func (w *Worker) Defer(ctx context.Context, id string) (err error) {
|
||||
return fmt.Errorf("defer: %w", err)
|
||||
}
|
||||
if d.State.IsTerminal() {
|
||||
return fmt.Errorf("defer: download %s is terminal (%s)", id, d.State)
|
||||
return fmt.Errorf("defer: download %s is terminal (%s): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
w.transition(ctx, *d, store.StateDeferred, "", "")
|
||||
@@ -556,7 +556,7 @@ func (w *Worker) Undo(ctx context.Context, id string) (err error) {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
}
|
||||
if batch == "" {
|
||||
return fmt.Errorf("undo: nothing to revert")
|
||||
return fmt.Errorf("undo: nothing to revert: %w", ErrConflict)
|
||||
}
|
||||
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
|
||||
if err != nil {
|
||||
@@ -653,8 +653,9 @@ func (w *Worker) Delete(ctx context.Context, id string) (err error) {
|
||||
// (в) Терминальный deleted с пользовательским маркером инициатора
|
||||
// (отличает от reconcile-deleted, который кладёт "reconcile").
|
||||
w.transition(ctx, *d, store.StateDeleted, "user_delete", "удалено пользователем")
|
||||
logctx.From(ctx).Info("download deleted by user",
|
||||
"from", d.State, "removed_links", removed, "code", "user_delete")
|
||||
// Запись физического эффекта (снятые ссылки) сверх перехода: from/code уже в
|
||||
// каноническом `state transition` выше — здесь только отличительное поле.
|
||||
logctx.From(ctx).Info("download deleted by user", "removed_links", removed)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -693,7 +694,7 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) (e
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
}
|
||||
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
|
||||
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition", candidateID)
|
||||
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition: %w", candidateID, ErrInvalidInput)
|
||||
}
|
||||
return w.chooseCandidateLocked(ctx, id, d, rec, *cand)
|
||||
}
|
||||
@@ -708,10 +709,10 @@ func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID s
|
||||
switch provider {
|
||||
case "tmdb", "tvdb", "imdb":
|
||||
default:
|
||||
return fmt.Errorf("add source: invalid provider %q (tmdb/tvdb/imdb)", provider)
|
||||
return fmt.Errorf("add source: invalid provider %q (tmdb/tvdb/imdb): %w", provider, ErrInvalidInput)
|
||||
}
|
||||
if providerID == "" {
|
||||
return fmt.Errorf("add source: empty id")
|
||||
return fmt.Errorf("add source: empty id: %w", ErrInvalidInput)
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -804,10 +805,10 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
|
||||
switch provider {
|
||||
case "tmdb", "tvdb", "imdb":
|
||||
default:
|
||||
return fmt.Errorf("set provider: invalid provider %q (tmdb/tvdb/imdb)", provider)
|
||||
return fmt.Errorf("set provider: invalid provider %q (tmdb/tvdb/imdb): %w", provider, ErrInvalidInput)
|
||||
}
|
||||
if providerID == "" {
|
||||
return fmt.Errorf("set provider: empty id")
|
||||
return fmt.Errorf("set provider: empty id: %w", ErrInvalidInput)
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -957,7 +958,9 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
|
||||
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(rd.Plan, "", tag, base)); lerr == nil {
|
||||
rd.Preview = links
|
||||
} else {
|
||||
log.Debug("review data build preview failed", "error", lerr)
|
||||
// Видимая деградация: без превью на экране ревью пропадает
|
||||
// кнопка «Применить» — не рядовой Debug, а WARN.
|
||||
log.Warn("review data build preview failed", "error", lerr)
|
||||
}
|
||||
}
|
||||
// Единый список источников: нейронка + кандидаты, каждый с
|
||||
@@ -1061,9 +1064,16 @@ func (w *Worker) effectivePlan(ctx context.Context, id string) (plan recognize.P
|
||||
return applyOverrides(plan, overrides), prov, pid, nil
|
||||
}
|
||||
|
||||
// reasonTitleFolderDesync — код причины ухода в review, когда у тайтла нашлось
|
||||
// несколько разных живых папок с одним матчем (правило сходимости папки).
|
||||
const reasonTitleFolderDesync = "title_folder_desync"
|
||||
// Коды причины (error_code) ухода задачи в review при раскладке (linkPlan) —
|
||||
// корреляционный ключ шага, на котором раскладка остановилась. Свод в одном
|
||||
// месте (как errCode* в worker.go); человекочитаемый текст кладётся в error_msg.
|
||||
const (
|
||||
reasonResolve = "resolve" // не удалось разрешить базу папки тайтла
|
||||
reasonBuild = "build" // не удалось построить план ссылок
|
||||
reasonPersist = "persist" // ссылки на диске, но учёт не записан
|
||||
reasonCollision = "collision" // целевой путь уже занят (layout.ErrCollision)
|
||||
reasonTitleFolderDesync = "title_folder_desync" // ≥2 разных живых папок тайтла с одним матчем
|
||||
)
|
||||
|
||||
// resolveFolderBase применяет правило сходимости папки (см. file-layout spec):
|
||||
// при подтверждённом матче наследует базу имени от живой папки-якоря того же
|
||||
@@ -1270,6 +1280,10 @@ func parseIgnored(s string) []string {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
// Ошибку разбора глотаем намеренно: битый JSON в override ignored_files
|
||||
// (не должен возникать — пишем его сами через json.Marshal) трактуем как
|
||||
// «нет игнора», а не роняем команду. Худший исход — файл не будет пропущен,
|
||||
// человек увидит его в превью и пометит заново.
|
||||
_ = json.Unmarshal([]byte(s), &out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -791,7 +791,8 @@ func (w *Worker) logCmd(ctx context.Context, cmd, id string, err error) {
|
||||
}
|
||||
log := logctx.FromOr(ctx, w.log)
|
||||
switch {
|
||||
case errors.Is(err, ErrConflict), errors.Is(err, ErrNotReady), errors.Is(err, store.ErrNotFound):
|
||||
case errors.Is(err, ErrConflict), errors.Is(err, ErrNotReady), errors.Is(err, ErrInvalidInput),
|
||||
errors.Is(err, store.ErrNotFound), errors.Is(err, layout.ErrCollision):
|
||||
log.Debug("command rejected", "command", cmd, "download_id", id, "error", err)
|
||||
default:
|
||||
log.Error("command failed", "command", cmd, "download_id", id, "error", err)
|
||||
@@ -810,12 +811,13 @@ func (w *Worker) Cancel(ctx context.Context, id string) (err error) {
|
||||
return fmt.Errorf("cancel: %w", err)
|
||||
}
|
||||
if d.State.IsTerminal() {
|
||||
return fmt.Errorf("cancel: download %s is already terminal (%s)", id, d.State)
|
||||
return fmt.Errorf("cancel: download %s is already terminal (%s): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "", ""); err != nil {
|
||||
return fmt.Errorf("cancel: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download cancelled", "from", d.State)
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("state transition",
|
||||
"from", d.State, "to", store.StateCancelled)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -831,7 +833,7 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
if d.State != store.StateFailed && d.State != store.StateStuck {
|
||||
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable", id, d.State)
|
||||
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable: %w", id, d.State, ErrConflict)
|
||||
}
|
||||
// Если раздача уже жива и ЗДОРОВА в qBittorrent — перецепляемся к ней,
|
||||
// повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add —
|
||||
@@ -901,7 +903,8 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||
"capability", capReview, "download_id", id, "error", err)
|
||||
}
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State)
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("state transition",
|
||||
"from", d.State, "to", store.StateDownloading)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user