byob-go-cli

net/http + RoundTripper middleware chain, not retryablehttp/resty

byob-http-client.1 http

Problem: hashicorp/go-retryablehttp, go-resty/resty, and similar wrappers bundle retry, logging, auth, and ergonomics into one opaque surface. That's convenient until you need to customize one layer — then the wrapper's surface fights you. And byob-release.8's pure-Go, stdlib-first posture would rather not take a dep it can replace with 60 lines.

Idea: compose http.RoundTripper middlewares over http.DefaultTransport. Each concern (user-agent, retry, logging) is a separate RoundTripper that wraps the next. Order matters: outer transports see the final outcome; inner transports see per-try state. Canonical order, outer → inner:

UserAgent → Retry → Logging → http.DefaultTransport

UserAgent outermost so the header is set once on the outgoing request and every retry attempt inherits it. Retry in the middle so each attempt is logged as a separate round-trip and the retry loop sees per-attempt status/error from the inner transport. Logging innermost (nearest the wire) so it records raw wire-level events before retry semantics interpret them.

Auth is out of scope for this pass; when added, it slots between UserAgent and Retry (401 triggers a refresh-then-retry).

Secrets: the Logging transport MUST redact Authorization, Cookie, Proxy-Authorization, Proxy-Authenticate, and Set-Cookie header values on both the request and the response before calling slog. A safeHeaders(h) helper that returns a shallow copy with these keys replaced by "<redacted>" is the minimum; a broader allowlist is safer still. Response-side is the easy miss — request-only redaction leaves Set-Cookie in the log whenever a server mints a new session. Logging request bodies is off by default; if opted in, wrap the body reader and redact known secret-bearing fields (password, token, api_key).

Tradeoffs: you write the transports yourself. Each is ~30–50 lines. Contract: every middleware must clone the request before mutating and must propagate ctx.Err() from the inner transport. The redaction discipline is easy to forget on a new header — put the helper in the same file as the transport and add an allowlist test.

Design

type userAgentRT struct {
    ua   string
    next http.RoundTripper
}

func (t *userAgentRT) RoundTrip(r *http.Request) (*http.Response, error) {
    r2 := r.Clone(r.Context())
    if r2.Header.Get("User-Agent") == "" {
        r2.Header.Set("User-Agent", t.ua)
    }
    return t.next.RoundTrip(r2)
}

var sensitiveHeaders = map[string]bool{
    "Authorization":       true,
    "Cookie":              true,
    "Proxy-Authorization": true,
    "Proxy-Authenticate":  true,
    "Set-Cookie":          true,
}

func safeHeaders(h http.Header) http.Header {
    out := make(http.Header, len(h))
    for k, v := range h {
        if sensitiveHeaders[http.CanonicalHeaderKey(k)] {
            out[k] = []string{"<redacted>"}
            continue
        }
        out[k] = v
    }
    return out
}

type loggingRT struct{ next http.RoundTripper }

func (t *loggingRT) RoundTrip(r *http.Request) (*http.Response, error) {
    start := time.Now()
    resp, err := t.next.RoundTrip(r)
    attrs := []any{
        "method", r.Method, "url", r.URL.String(),
        "reqHeaders", safeHeaders(r.Header),
        "status", statusOf(resp), "err", err,
        "dur", time.Since(start),
    }
    if resp != nil {
        attrs = append(attrs, "respHeaders", safeHeaders(resp.Header))
    }
    slog.DebugContext(r.Context(), "http", attrs...)
    return resp, err
}

func buildTransport(ua string, inner http.RoundTripper) http.RoundTripper {
    return &userAgentRT{ua: ua, next:
        &retryRT{next:
            &loggingRT{next: inner}}}
}