fix(transport): keep harmless query values visible in --debug output

--debug replaced every query value with REDACTED, so it could not show which
filters were sent. Redact only parameters that can authenticate a request
(tokens, passwords, secrets, signatures, storage credentials, OAuth codes and
the OAuth state), and log the sanitized server message of failed responses.

A 404 now names the request instead of blaming the instance, and API errors
recorded from a request keep its instance and credential so a caller can look
up why it failed. Network failures keep the server they could not reach.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 22:48:49 +02:00
1 parent 62962b4f70
commit 0254c0509e
7 files changed
+225 -17

No files matched your search

+73 -2
View File
@@ -32,6 +32,12 @@ type RequestSigner interface {
type APIError struct {
Status int
Method, Endpoint, Message string
// Instance is the installation URL the failed request was sent to, when it was recorded
// from a request.
Instance string
// apiBase and lookup repeat reads on the failed request's instance with its credential.
apiBase string
lookup http.RoundTripper
}
func (e *APIError) Error() string {
@@ -42,7 +48,11 @@ func (e *APIError) Error() string {
case 403:
text = "permission denied by the Gitea instance"
case 404:
text = "resource or API endpoint unavailable on the selected Gitea instance"
// Gitea's 404 message is usually just "not found"; the request names what was missing.
text = "not found (" + e.request() + ")"
if strings.EqualFold(e.Message, "not found") {
return text
}
case 429:
text = "Gitea rate limit exceeded; try again later"
default:
@@ -54,9 +64,52 @@ func (e *APIError) Error() string {
return text
}
// request describes the failed request, such as "HTTP 404 GET /api/v1/repos/alice/x".
func (e *APIError) request() string {
return strings.TrimSpace(fmt.Sprintf("HTTP %d %s %s", e.Status, e.Method, e.Endpoint))
}
// bind records the instance of the request to u and the credentialed transport rt, so the
// failure can later be explained with Lookup.
func (e *APIError) bind(u *url.URL, rt http.RoundTripper) {
prefix, _, ok := strings.Cut(u.Path, "/api/v1/")
if !ok {
return
}
instance := url.URL{Scheme: u.Scheme, Host: u.Host, Path: prefix}
e.Instance = instance.String()
instance.Path += "/api/v1/"
e.apiBase, e.lookup = instance.String(), rt
}
// Lookup reads endpoint, a path below the API base such as "repos/alice/project", with the
// instance and credential of the request that failed, and decodes a successful JSON response
// into out. It returns the response status, or 0 when the error was not recorded from a
// request or the lookup itself failed.
func (e *APIError) Lookup(ctx context.Context, endpoint string, out any) int {
if e.lookup == nil {
return 0
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.apiBase+strings.TrimLeft(endpoint, "/"), nil)
if err != nil {
return 0
}
req.Header.Set("Accept", "application/json")
resp, err := e.lookup.RoundTrip(req)
if err != nil {
return 0
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 && out != nil && json.NewDecoder(io.LimitReader(resp.Body, maxLookupBody)).Decode(out) != nil {
return 0
}
return resp.StatusCode
}
const (
maxErrorBody = 4 << 10
maxErrorMessage = 300
maxLookupBody = 1 << 20
)
// newAPIError keeps only a bounded, printable JSON "message" field of a failed response body.
@@ -175,6 +228,20 @@ func (c *Client) URL(endpoint string) (string, error) {
return base.String(), nil
}
// RequestFailure reports a request that received no HTTP response, such as a refused
// connection or an untrusted certificate.
type RequestFailure struct {
// Action names what failed, such as "API request failed".
Action string
// Server is the scheme and host the request was sent to, without path or query.
Server string
// Err is the network or TLS cause.
Err error
}
func (e *RequestFailure) Error() string { return e.Action + ": " + e.Err.Error() }
func (e *RequestFailure) Unwrap() error { return e.Err }
// RequestError keeps the network or TLS cause of a failed request. The cause of a *url.Error
// omits the request URL, whose query may carry secrets.
func RequestError(ctx context.Context, action string, err error) error {
@@ -182,7 +249,11 @@ func RequestError(ctx context.Context, action string, err error) error {
return ctx.Err()
}
if urlErr, ok := errors.AsType[*url.Error](err); ok {
return fmt.Errorf("%s: %w", action, urlErr.Err)
failure := &RequestFailure{Action: action, Err: urlErr.Err}
if u, parseErr := url.Parse(urlErr.URL); parseErr == nil {
failure.Server = (&url.URL{Scheme: u.Scheme, Host: u.Host}).String()
}
return failure
}
return errors.New(action + "; check connectivity and TLS settings")
}