Require secrets for ProviderError redaction and omit unredactable bodies
NewProviderError now takes the secrets to redact as a required []string instead of an optional variadic. When no non-empty secret is given the body cannot be redacted, so no excerpt is stored and Body()/Error() never expose raw vendor output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,17 +56,24 @@ type ProviderError struct {
|
|||||||
body string
|
body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProviderError returns the error for an unexpected HTTP status. Every
|
// NewProviderError returns the error for an unexpected HTTP status.
|
||||||
// occurrence of each non-empty secret (the API key, at least) in body is
|
// secrets must hold the API key (and any other credential sent with the
|
||||||
// replaced before the body is cut to MaxBodyExcerpt bytes, so no part of a
|
// request). Every occurrence of each non-empty secret in body is replaced
|
||||||
// secret survives.
|
// before the body is cut to MaxBodyExcerpt bytes, so no part of a secret
|
||||||
func NewProviderError(status int, body []byte, secrets ...string) *ProviderError {
|
// survives. If secrets holds no non-empty value the body cannot be
|
||||||
|
// redacted, so no excerpt is kept at all.
|
||||||
|
func NewProviderError(status int, body []byte, secrets []string) *ProviderError {
|
||||||
s := string(body)
|
s := string(body)
|
||||||
|
redactedAny := false
|
||||||
for _, secret := range secrets {
|
for _, secret := range secrets {
|
||||||
if secret != "" {
|
if secret != "" {
|
||||||
s = strings.ReplaceAll(s, secret, redacted)
|
s = strings.ReplaceAll(s, secret, redacted)
|
||||||
|
redactedAny = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !redactedAny {
|
||||||
|
return &ProviderError{status: status}
|
||||||
|
}
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if len(s) > MaxBodyExcerpt {
|
if len(s) > MaxBodyExcerpt {
|
||||||
cut := MaxBodyExcerpt
|
cut := MaxBodyExcerpt
|
||||||
@@ -81,7 +88,8 @@ func NewProviderError(status int, body []byte, secrets ...string) *ProviderError
|
|||||||
// Status returns the HTTP status code.
|
// Status returns the HTTP status code.
|
||||||
func (e *ProviderError) Status() int { return e.status }
|
func (e *ProviderError) Status() int { return e.status }
|
||||||
|
|
||||||
// Body returns the redacted, truncated response-body excerpt.
|
// Body returns the redacted, truncated response-body excerpt; it is empty
|
||||||
|
// when the vendor sent no body or no secret was given to redact it with.
|
||||||
func (e *ProviderError) Body() string { return e.body }
|
func (e *ProviderError) Body() string { return e.body }
|
||||||
|
|
||||||
func (e *ProviderError) Error() string {
|
func (e *ProviderError) Error() string {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestRateLimitedError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProviderError(t *testing.T) {
|
func TestProviderError(t *testing.T) {
|
||||||
err := fmt.Errorf("gemini: %w", NewProviderError(503, []byte(` {"error":"overloaded"} `)))
|
err := fmt.Errorf("gemini: %w", NewProviderError(503, []byte(` {"error":"overloaded"} `), []string{"sk-test"}))
|
||||||
if !errors.Is(err, ErrProvider) {
|
if !errors.Is(err, ErrProvider) {
|
||||||
t.Fatal("errors.Is(err, ErrProvider) = false")
|
t.Fatal("errors.Is(err, ErrProvider) = false")
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ func TestProviderError(t *testing.T) {
|
|||||||
if got := err.Error(); got != `gemini: llm: provider error: HTTP 503: {"error":"overloaded"}` {
|
if got := err.Error(); got != `gemini: llm: provider error: HTTP 503: {"error":"overloaded"}` {
|
||||||
t.Fatalf("Error() = %q", got)
|
t.Fatalf("Error() = %q", got)
|
||||||
}
|
}
|
||||||
if got := NewProviderError(500, nil).Error(); got != "llm: provider error: HTTP 500" {
|
if got := NewProviderError(500, nil, []string{"sk-test"}).Error(); got != "llm: provider error: HTTP 500" {
|
||||||
t.Fatalf("Error() without body = %q", got)
|
t.Fatalf("Error() without body = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ func TestProviderError(t *testing.T) {
|
|||||||
func TestProviderErrorRedactsSecrets(t *testing.T) {
|
func TestProviderErrorRedactsSecrets(t *testing.T) {
|
||||||
const key = "sk-live-0123456789abcdef"
|
const key = "sk-live-0123456789abcdef"
|
||||||
body := []byte(`{"error":"invalid key sk-live-0123456789abcdef","echo":"Bearer sk-live-0123456789abcdef"}`)
|
body := []byte(`{"error":"invalid key sk-live-0123456789abcdef","echo":"Bearer sk-live-0123456789abcdef"}`)
|
||||||
pe := NewProviderError(400, body, key, "")
|
pe := NewProviderError(400, body, []string{key, ""})
|
||||||
for _, s := range []string{pe.Error(), pe.Body()} {
|
for _, s := range []string{pe.Error(), pe.Body()} {
|
||||||
if strings.Contains(s, key) {
|
if strings.Contains(s, key) {
|
||||||
t.Fatalf("secret leaked: %q", s)
|
t.Fatalf("secret leaked: %q", s)
|
||||||
@@ -88,7 +88,7 @@ func TestProviderErrorRedactsBeforeTruncating(t *testing.T) {
|
|||||||
// The key starts just before the excerpt limit, so truncating first
|
// The key starts just before the excerpt limit, so truncating first
|
||||||
// would leave its prefix in the excerpt.
|
// would leave its prefix in the excerpt.
|
||||||
body := strings.Repeat("x", MaxBodyExcerpt-5) + key
|
body := strings.Repeat("x", MaxBodyExcerpt-5) + key
|
||||||
pe := NewProviderError(500, []byte(body), key)
|
pe := NewProviderError(500, []byte(body), []string{key})
|
||||||
if strings.Contains(pe.Body(), "sk-se") {
|
if strings.Contains(pe.Body(), "sk-se") {
|
||||||
t.Fatalf("secret prefix leaked: %q", pe.Body())
|
t.Fatalf("secret prefix leaked: %q", pe.Body())
|
||||||
}
|
}
|
||||||
@@ -96,9 +96,26 @@ func TestProviderErrorRedactsBeforeTruncating(t *testing.T) {
|
|||||||
|
|
||||||
func TestProviderErrorTruncatesOnRuneBoundary(t *testing.T) {
|
func TestProviderErrorTruncatesOnRuneBoundary(t *testing.T) {
|
||||||
body := strings.Repeat("a", MaxBodyExcerpt-1) + "é" + strings.Repeat("b", 100)
|
body := strings.Repeat("a", MaxBodyExcerpt-1) + "é" + strings.Repeat("b", 100)
|
||||||
pe := NewProviderError(500, []byte(body))
|
pe := NewProviderError(500, []byte(body), []string{"sk-test"})
|
||||||
want := strings.Repeat("a", MaxBodyExcerpt-1) + "…"
|
want := strings.Repeat("a", MaxBodyExcerpt-1) + "…"
|
||||||
if pe.Body() != want {
|
if pe.Body() != want {
|
||||||
t.Fatalf("Body() has %d bytes, want %d", len(pe.Body()), len(want))
|
t.Fatalf("Body() has %d bytes, want %d", len(pe.Body()), len(want))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorOmitsBodyWithoutSecret(t *testing.T) {
|
||||||
|
const key = "sk-live-0123456789abcdef"
|
||||||
|
body := []byte(`{"error":"invalid key sk-live-0123456789abcdef"}`)
|
||||||
|
for _, secrets := range [][]string{nil, {}, {""}} {
|
||||||
|
pe := NewProviderError(401, body, secrets)
|
||||||
|
if pe.Body() != "" {
|
||||||
|
t.Fatalf("secrets %q: Body() = %q, want empty", secrets, pe.Body())
|
||||||
|
}
|
||||||
|
if got := pe.Error(); got != "llm: provider error: HTTP 401" {
|
||||||
|
t.Fatalf("secrets %q: Error() = %q", secrets, got)
|
||||||
|
}
|
||||||
|
if strings.Contains(pe.Error(), key) {
|
||||||
|
t.Fatalf("secrets %q: secret leaked: %q", secrets, pe.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user