Логирование: классификация доменных ошибок (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:
av
2026-07-10 14:57:12 +03:00
co-authored by Claude Opus 4.8
parent 864c44aebd
commit 7d8a455e47
13 changed files with 247 additions and 123 deletions
+1 -1
View File
@@ -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
}
+15 -5
View File
@@ -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, "действие недоступно в текущем состоянии"
+35
View File
@@ -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},
+3 -3
View File
@@ -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
}