Files
jellybit/internal/metadata/http.go
T
avandClaude Opus 4.8 f8fb4fabb3 Логирование: доменная граница ошибок + защита секретов в логах
Приём торрента через Telegram молча падал без записи в логах. Разобрали
цепочку и починили логирование/обработку ошибок по конвенции logging.md
(логирует граница домена один раз, транспорты — нет).

Доменная граница логирует исход:
- ingest.Ingest: сбой БД → ERROR, невалидный источник → DEBUG;
- команды воркера (Apply/Cancel/Retry/Refine/…) — единый чокпоинт logCmd
  (ERROR для инфраструктурного сбоя; DEBUG для conflict/not-ready/not-found),
  закрывает и Telegram-, и HTTP-путь; дублирующие ERROR-логи в tgbot сняты;
- внутренний логгер tgbotapi заведён в slog: сбои long-poll getUpdates
  больше не уходят в stdlib log мимо структурированных логов;
- тихое закрытие канала обновлений бота → ERROR.

Защита секретов (инвариант «секреты не в логи»):
- общий logging.SanitizeErr убирает URL из *url.Error;
- закрыты утечки токена бота (getMe на старте, getFile, Send/Request)
  и api_key TMDB (query-параметр, попадавший в *url.Error на ERROR);
- покрыто тестом internal/logging/sanitize_test.go.

Ревью двумя сабагентами (fable): инфраструктурные и доменные ошибки.
Отложено (не в scope этого коммита): обёртка ErrConflict в
Cancel/Defer/Retry и классификация 500→409/400, обновление docs/conventions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:29:38 +03:00

117 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package metadata
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"time"
"git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/logging"
)
const defaultTimeout = 10 * time.Second
// newHTTPClient собирает http.Client с опциональным прокси и таймаутом.
func newHTTPClient(proxy string, timeout time.Duration) (*http.Client, error) {
if timeout <= 0 {
timeout = defaultTimeout
}
transport := http.DefaultTransport
if proxy != "" {
u, err := url.Parse(proxy)
if err != nil {
return nil, fmt.Errorf("metadata: parse proxy %q: %w", proxy, err)
}
// Клонируем дефолтный транспорт (dial/TLS-таймауты, keep-alive), а не
// собираем голый — иначе при живом-но-залипшем прокси полагались бы
// только на общий Client.Timeout. Он остаётся верхней границей запроса.
t := http.DefaultTransport.(*http.Transport).Clone()
t.Proxy = http.ProxyURL(u)
transport = t
}
return &http.Client{Timeout: timeout, Transport: transport}, nil
}
const maxBody = 4 << 20 // 4 MiB — потолок на тело ответа
// getJSON выполняет GET и декодирует JSON-ответ в out. headers — опц.
// дополнительные заголовки (напр. Authorization). service/operation — поля
// ext.* для телеметрии вызова.
func getJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, operation, rawURL string, headers map[string]string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return fmt.Errorf("metadata: build request: %w", err)
}
req.Header.Set("Accept", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
return doJSON(ctx, hc, log, service, operation, req, out)
}
// postJSON выполняет POST с JSON-телом и декодирует ответ.
func postJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, operation, rawURL string, body, out any) error {
payload, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("metadata: marshal body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("metadata: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
return doJSON(ctx, hc, log, service, operation, req, out)
}
// doJSON выполняет запрос и декодирует ответ, логируя исход телеметрией ext.*
// (логическая operation вместо URL: у TMDB api_key передаётся query-параметром,
// его нельзя светить в логах). Логгер берётся из ctx (scoped-логгер загрузки),
// при отсутствии — переданный fallback.
func doJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, operation string, req *http.Request, out any) error {
log = logctx.FromOr(ctx, log)
call := logging.ExtCall{Service: service, Operation: operation, Start: time.Now()}
resp, err := hc.Do(req)
if err != nil {
// Транспортный сбой несёт *url.Error с полным URL, а у TMDB api_key —
// query-параметр: санитизируем до логирования И до обёртки, чтобы ключ
// не утёк ни в лог, ни вверх по цепочке %w.
err = logging.SanitizeErr(err)
call.Failure(log, err)
return fmt.Errorf("metadata: request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
call.Status = resp.StatusCode
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
call.Failure(log, err)
return fmt.Errorf("metadata: read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
err := fmt.Errorf("metadata: status %d: %s", resp.StatusCode, snippet(raw))
call.Failure(log, err)
return err
}
if err := json.Unmarshal(raw, out); err != nil {
call.Failure(log, err)
return fmt.Errorf("metadata: decode: %w (body: %s)", err, snippet(raw))
}
call.Success(log)
return nil
}
func snippet(b []byte) string {
const max = 200
if len(b) > max {
return string(b[:max]) + "…"
}
return string(b)
}