ingest: закрыты мелочи приёма — вырожденное имя, контракт Result, корреляция add

- имя раздачи нормализуется на границе разбора: вырожденное `-`
  (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше
  не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки
  вывода имени
- контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и
  удерживается структурно; три транспорта перестали обещать идентификатор,
  которого нет, и коррелируют отказ по request_id
- scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах
  воркера — записи об отказе qBittorrent и метабаз получили download_id
  и infohash; граница разбора bencode записана в docs/research
This commit is contained in:
av
2026-08-06 18:20:12 +03:00
parent 52615e4e49
commit d081ef1d30
30 changed files with 2190 additions and 63 deletions
+7 -5
View File
@@ -432,7 +432,9 @@ func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
res, err := s.deps.Ingestor.Ingest(r.Context(), req)
if err != nil {
redirectErr(w, r, userErr(r, err, res.DownloadID))
// Нулевой Result на любом пути ошибки — контракт ingest.Ingest;
// корреляционный ключ веб-формы, как и REST, — request_id.
redirectErr(w, r, userErr(r, err, ""))
return
}
if res.Deduplicated {
@@ -585,10 +587,10 @@ func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
}
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
if err != nil {
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit) —
// тогда коррелируем по download_id, иначе (ранний разбор источника) по
// request_id.
s.apiErr(w, r, err, res.DownloadID)
// Приём на любом пути ошибки возвращает нулевой Result (контракт
// ingest.Ingest) — идентификатора загрузки тут нет и быть не может,
// коррелируем по request_id.
s.apiErr(w, r, err, "")
return
}
status := http.StatusCreated
+50
View File
@@ -2,6 +2,9 @@ package httpapi_test
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/http"
"net/url"
@@ -85,3 +88,50 @@ func TestUIAddUrlencoded(t *testing.T) {
t.Errorf("urlencoded source не проброшен: %q", ing.lastReq.Source)
}
}
// Отказ приёма на HTTP-границе: идентификатора загрузки нет (контракт
// ingest.Ingest — нулевой Result на любом пути ошибки), поэтому корреляционным
// ключом остаётся request_id запроса. Проверяются оба HTTP-транспорта: REST
// отдаёт ключ полем тела, веб-форма — текстом флеш-сообщения в редиректе.
func TestIngestErrorCorrelatesByRequestID(t *testing.T) {
t.Run("REST", func(t *testing.T) {
ing := &fakeIngestor{err: fmt.Errorf("ingest: create download: %w", errors.New("boom"))}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
strings.NewReader(`{"source":"magnet:?xt=urn:btih:abc"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if s, _ := body["request_id"].(string); s == "" {
t.Errorf("в теле отказа нет request_id: %v", body)
}
if _, ok := body["download_id"]; ok {
t.Errorf("в теле отказа обещан download_id: %v", body)
}
})
t.Run("веб-форма", func(t *testing.T) {
ing := &fakeIngestor{err: fmt.Errorf("ingest: create download: %w", errors.New("boom"))}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads",
url.Values{"source": {"magnet:?xt=urn:btih:abc"}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
loc := resp.Header.Get("Location")
if !strings.Contains(loc, "request_id%3D") && !strings.Contains(loc, "request_id=") {
t.Errorf("в сообщении отказа нет request_id: %q", loc)
}
if strings.Contains(loc, "download_id") {
t.Errorf("в сообщении отказа обещан download_id: %q", loc)
}
})
}