Files
jellybit/internal/metadata/http.go
T
avandClaude Opus 4.8 612344bab3 конвенции: перенести механизируемое в golangci-lint и internal/archrules
Правило, которое проверяет машина, не должно оставаться прозой: файл конвенций
на сотни строк размазывает внимание по тривиальному — модель добросовестно
проверит именование полей лога и не дойдёт до формы решения.

Включены sloglint (константный msg, стиль ключ-значение), forbidigo (fmt.Print*,
os.Getenv, time.Now мимо store.Now), errorlint (сравнение ошибок), depguard
(сторонние пакеты ошибок). internal/archrules — сканеры на то, что линтером не
выражается: направление зависимостей ядро↔транспорты, AUTOINCREMENT и серверное
время в новых миграциях, матчинг ошибки по тексту.

Код приведён к правилам: logging.StartCall как единая точка отсчёта длительности
внешних вызовов, store.Now вместо time.Now в httpapi и часах воркера,
slog.DiscardHandler в тестах.

Перенесённое вычеркнуто из docs/conventions/* и openspec/config.yaml — прозой
осталось только то, что правилом не выражается.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:17:40 +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.StartCall(service, operation)
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)
}