package metadata import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "time" ) 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). func getJSON(ctx context.Context, hc *http.Client, log *slog.Logger, 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(hc, log, req, out) } // postJSON выполняет POST с JSON-телом и декодирует ответ. func postJSON(ctx context.Context, hc *http.Client, log *slog.Logger, 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(hc, log, req, out) } // doJSON выполняет запрос и декодирует ответ, логируя исход. В лог идут только // host и path (без query) — у TMDB api_key передаётся query-параметром, его // нельзя светить в логах. func doJSON(hc *http.Client, log *slog.Logger, req *http.Request, out any) error { if log == nil { log = slog.Default() } start := time.Now() resp, err := hc.Do(req) if err != nil { log.Warn("metadata: request failed", "method", req.Method, "host", req.URL.Host, "path", req.URL.Path, "duration", time.Since(start), "err", err) return fmt.Errorf("metadata: request: %w", err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) if err != nil { log.Warn("metadata: read body failed", "host", req.URL.Host, "path", req.URL.Path, "err", err) return fmt.Errorf("metadata: read body: %w", err) } if resp.StatusCode != http.StatusOK { log.Warn("metadata: non-ok status", "method", req.Method, "host", req.URL.Host, "path", req.URL.Path, "status", resp.StatusCode, "duration", time.Since(start)) return fmt.Errorf("metadata: status %d: %s", resp.StatusCode, snippet(raw)) } if err := json.Unmarshal(raw, out); err != nil { log.Warn("metadata: decode failed", "host", req.URL.Host, "path", req.URL.Path, "err", err) return fmt.Errorf("metadata: decode: %w (body: %s)", err, snippet(raw)) } log.Debug("metadata: request ok", "method", req.Method, "host", req.URL.Host, "path", req.URL.Path, "duration", time.Since(start)) return nil } func snippet(b []byte) string { const max = 200 if len(b) > max { return string(b[:max]) + "…" } return string(b) }