package executor import ( "context" "net/http" "net/http/httptest" "testing" "time" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator") func TestOpenAICompatRetryAfter(t *testing.T) { now := time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC) tests := []struct { name string status int headers http.Header body string want *time.Duration }{ { name: "delta seconds header", status: http.StatusTooManyRequests, headers: http.Header{"Retry-After": {"17"}}, want: durationPointer(17 * time.Second), }, { name: "http date header", status: http.StatusTooManyRequests, headers: http.Header{"Retry-After": {now.Add(23 * time.Second).Format(http.TimeFormat)}}, want: durationPointer(23 * time.Second), }, { name: "explicit TPM code fallback", status: http.StatusTooManyRequests, body: `{"error":{"code":"ModelAccountTpmRateLimitExceeded","message":"TPM limit exceeded"}}`, want: durationPointer(time.Minute), }, { name: "TPM message fallback", status: http.StatusTooManyRequests, body: `{"error":{"message":"TPM (Tokens Per Minute) limit of this model is exceeded"}}`, want: durationPointer(time.Minute), }, { name: "provider header wins over fallback", status: http.StatusTooManyRequests, headers: http.Header{"Retry-After": {"5"}}, body: `{"error":{"code":"ModelAccountTpmRateLimitExceeded"}}`, want: durationPointer(5 * time.Second), }, { name: "generic 429 has no invented deadline", status: http.StatusTooManyRequests, body: `{"error":{"code":"rate_limit"}}`, }, { name: "non-429 ignores header", status: http.StatusServiceUnavailable, headers: http.Header{"Retry-After": {"30"}}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got := openAICompatRetryAfter(test.status, test.headers, []byte(test.body), now) if test.want == nil { if got != nil { t.Fatalf("retry-after = %v, want nil", *got) } return } if got == nil || *got != *test.want { t.Fatalf("retry-after = %v, want %v", got, *test.want) } }) }} func TestOpenAICompatExecutorPropagatesRetryAfter(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Retry-After", "7") w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"error":{"code":"rate_limit","message":"try later"}}`)) })) t.Cleanup(server.Close) executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{}) auth := &cliproxyauth.Auth{Attributes: map[string]string{ "base_url": server.URL + "/v1", "api_key": "test", }} request := cliproxyexecutor.Request{ Model: "compatible-model", Payload: []byte(`{"model":"compatible-model","messages":[{"role":"user","content":"hi"}]}`), } tests := []struct { name string invoke func() error }{ { name: "nonstream", invoke: func() error { _, errExecute := executor.Execute(context.Background(), auth, request, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FromString("openai"), }) return errExecute }, }, { name: "stream bootstrap", invoke: func() error { _, errExecute := executor.ExecuteStream(context.Background(), auth, request, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FromString("openai"), Stream: true, }) return errExecute }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { errExecute := test.invoke() if errExecute == nil { t.Fatal("expected rate-limit error") } retryable, ok := errExecute.(interface{ RetryAfter() *time.Duration }) if !ok || retryable.RetryAfter() == nil || *retryable.RetryAfter() != 7*time.Second { t.Fatalf("retry-after = %v, want 7s", retryable) } }) }} func durationPointer(value time.Duration) *time.Duration { return &value}
package executor
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
func TestOpenAICompatRetryAfter(t *testing.T) {
now := time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
status int
headers http.Header
body string
want *time.Duration
}{
{
name: "delta seconds header",
status: http.StatusTooManyRequests,
headers: http.Header{"Retry-After": {"17"}},
want: durationPointer(17 * time.Second),
},
{
name: "http date header",
status: http.StatusTooManyRequests,
headers: http.Header{"Retry-After": {now.Add(23 * time.Second).Format(http.TimeFormat)}},
want: durationPointer(23 * time.Second),
},
{
name: "explicit TPM code fallback",
status: http.StatusTooManyRequests,
body: `{"error":{"code":"ModelAccountTpmRateLimitExceeded","message":"TPM limit exceeded"}}`,
want: durationPointer(time.Minute),
},
{
name: "TPM message fallback",
status: http.StatusTooManyRequests,
body: `{"error":{"message":"TPM (Tokens Per Minute) limit of this model is exceeded"}}`,
want: durationPointer(time.Minute),
},
{
name: "provider header wins over fallback",
status: http.StatusTooManyRequests,
headers: http.Header{"Retry-After": {"5"}},
body: `{"error":{"code":"ModelAccountTpmRateLimitExceeded"}}`,
want: durationPointer(5 * time.Second),
},
{
name: "generic 429 has no invented deadline",
status: http.StatusTooManyRequests,
body: `{"error":{"code":"rate_limit"}}`,
},
{
name: "non-429 ignores header",
status: http.StatusServiceUnavailable,
headers: http.Header{"Retry-After": {"30"}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := openAICompatRetryAfter(test.status, test.headers, []byte(test.body), now)
if test.want == nil {
if got != nil {
t.Fatalf("retry-after = %v, want nil", *got)
}
return
}
if got == nil || *got != *test.want {
t.Fatalf("retry-after = %v, want %v", got, *test.want)
}
})
}
}
func TestOpenAICompatExecutorPropagatesRetryAfter(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Retry-After", "7")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"error":{"code":"rate_limit","message":"try later"}}`))
}))
t.Cleanup(server.Close)
executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{})
auth := &cliproxyauth.Auth{Attributes: map[string]string{
"base_url": server.URL + "/v1",
"api_key": "test",
}}
request := cliproxyexecutor.Request{
Model: "compatible-model",
Payload: []byte(`{"model":"compatible-model","messages":[{"role":"user","content":"hi"}]}`),
}
tests := []struct {
name string
invoke func() error
}{
{
name: "nonstream",
invoke: func() error {
_, errExecute := executor.Execute(context.Background(), auth, request, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FromString("openai"),
})
return errExecute
},
},
{
name: "stream bootstrap",
invoke: func() error {
_, errExecute := executor.ExecuteStream(context.Background(), auth, request, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FromString("openai"),
Stream: true,
})
return errExecute
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
errExecute := test.invoke()
if errExecute == nil {
t.Fatal("expected rate-limit error")
}
retryable, ok := errExecute.(interface{ RetryAfter() *time.Duration })
if !ok || retryable.RetryAfter() == nil || *retryable.RetryAfter() != 7*time.Second {
t.Fatalf("retry-after = %v, want 7s", retryable)
}
})
}
}
func durationPointer(value time.Duration) *time.Duration {
return &value
}