Strip the API key from fixed error text and keep typed matches
ProviderError and the new NewRateLimitedError constructor now keep the credential and remove it from their complete Error() text, including fixed words such as "HTTP" and "rate", so errors.As to them succeeds through the adapter's redaction stand-in instead of being refused. The openai adapter builds RateLimitedError with the key. Tests cover the keys "HTTP" (500), "rate" (429) and "openai" (401 and no model), checking Error(), Body(), every unwrap layer, errors.Is and errors.As. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -446,7 +446,7 @@ func (p *Provider) statusError(resp *http.Response, body []byte) error {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrAuth)
|
||||
case http.StatusTooManyRequests:
|
||||
return &llm.RateLimitedError{RetryAfter: retryAfter(resp.Header.Get("Retry-After"), time.Now())}
|
||||
return llm.NewRateLimitedError(retryAfter(resp.Header.Get("Retry-After"), time.Now()), p.apiKey)
|
||||
case http.StatusRequestTimeout:
|
||||
return fmt.Errorf("HTTP %d: %w", resp.StatusCode, llm.ErrTimeout)
|
||||
default:
|
||||
@@ -518,8 +518,10 @@ func (p *Provider) transportError(err error) error {
|
||||
// API key. Its text is the layer's text with the key redacted, and Unwrap
|
||||
// returns the layer's causes passed through redact, so no error reached by
|
||||
// unwrapping has text holding the key. Is consults the original layer and
|
||||
// its chain, so sentinels still match. As does too, but refuses a match
|
||||
// whose text holds the key, so it never hands back an unredacted error.
|
||||
// its chain, so sentinels still match. As does too. The llm typed errors
|
||||
// remove the key from their own complete text, fixed words included, so
|
||||
// they always match; As refuses only a match from outside llm whose text
|
||||
// holds the key, so it never hands back an unredacted error.
|
||||
type redactedError struct {
|
||||
text string
|
||||
err error
|
||||
|
||||
@@ -1042,3 +1042,80 @@ func TestGenerateMalformedJSONWithShortKeyNeverLeaksKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertLayersNoExactKey fails if the text of err or of any error reached
|
||||
// from it by errors.Unwrap holds key, matched case-sensitively.
|
||||
func assertLayersNoExactKey(t *testing.T, err error, key string) {
|
||||
t.Helper()
|
||||
for layer := err; layer != nil; layer = errors.Unwrap(layer) {
|
||||
if strings.Contains(layer.Error(), key) {
|
||||
t.Fatalf("layer leaks %q: %q", key, layer.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderErrorWithFixedWordKeyKeepsTypedMatch uses the fixed word
|
||||
// "HTTP" of ProviderError's text as the API key: the text must drop it and
|
||||
// errors.As must still reach the ProviderError.
|
||||
func TestProviderErrorWithFixedWordKeyKeepsTypedMatch(t *testing.T) {
|
||||
const key = "HTTP"
|
||||
p := keyServerWithKey(t, key, 500, `{"error":"down"}`)
|
||||
_, err := p.Generate(context.Background(), schemaRequest())
|
||||
var pe *llm.ProviderError
|
||||
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != 500 {
|
||||
t.Fatalf("err = %v, want a 500 ProviderError", err)
|
||||
}
|
||||
if strings.Contains(pe.Error(), key) || strings.Contains(pe.Body(), key) {
|
||||
t.Fatalf("ProviderError leaks the key: Error %q, Body %q", pe.Error(), pe.Body())
|
||||
}
|
||||
assertLayersNoExactKey(t, err, key)
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
|
||||
// TestRateLimitedErrorWithFixedWordKeyKeepsTypedMatch uses the fixed word
|
||||
// "rate" of RateLimitedError's text as the API key.
|
||||
func TestRateLimitedErrorWithFixedWordKeyKeepsTypedMatch(t *testing.T) {
|
||||
const key = "rate"
|
||||
srv := newVendorServer(t, reply{status: 429, header: map[string]string{"Retry-After": "17"}})
|
||||
p, err := New(Config{BaseURL: srv.URL + "/v1", APIKey: key, Model: "test-model", StructuredOutput: PromptOnly})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.Generate(context.Background(), schemaRequest())
|
||||
var rl *llm.RateLimitedError
|
||||
if !errors.Is(err, llm.ErrRateLimited) || !errors.As(err, &rl) || rl.RetryAfter != 17*time.Second {
|
||||
t.Fatalf("err = %v, want a RateLimitedError with RetryAfter 17s", err)
|
||||
}
|
||||
if strings.Contains(rl.Error(), key) {
|
||||
t.Fatalf("RateLimitedError leaks the key: %q", rl.Error())
|
||||
}
|
||||
assertLayersNoExactKey(t, err, key)
|
||||
assertChainNoExactKey(t, err, key)
|
||||
}
|
||||
|
||||
// TestGenerateWithAdapterNameKeyNeverLeaksKey uses the fixed prefix
|
||||
// "openai" of the adapter's errors as the API key.
|
||||
func TestGenerateWithAdapterNameKeyNeverLeaksKey(t *testing.T) {
|
||||
const key = "openai"
|
||||
t.Run("401", func(t *testing.T) {
|
||||
p := keyServerWithKey(t, key, 401, `{"error":{"message":"bad key"}}`)
|
||||
_, err := p.Generate(context.Background(), schemaRequest())
|
||||
if !errors.Is(err, llm.ErrAuth) {
|
||||
t.Fatalf("err = %v, want ErrAuth", err)
|
||||
}
|
||||
assertLayersNoExactKey(t, err, key)
|
||||
assertChainNoExactKey(t, err, key)
|
||||
})
|
||||
t.Run("no model", func(t *testing.T) {
|
||||
p, err := New(Config{BaseURL: "http://127.0.0.1:1/v1", APIKey: key, StructuredOutput: PromptOnly})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.Generate(context.Background(), schemaRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Generate succeeded without a model")
|
||||
}
|
||||
assertLayersNoExactKey(t, err, key)
|
||||
assertChainNoExactKey(t, err, key)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user