Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ coverage.*
.DS_Store
.odek-artifacts/
.tmp-spincheck/
.env

.env
*.env
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,14 @@ make test-race # race detector
make lint # golangci-lint (v2 config)
```

Live end-to-end tests against the real DeepSeek API (tag-gated, never run in CI):

```bash
go test -tags e2e -run 'TestE2E' -timeout 15m -v .
```

Credentials come from `DEEPSEEK_API_KEY` in the environment or a repo-root `.env` file (`KEY=VALUE`); the file is gitignored and its contents are never logged. Tests skip cleanly when no key resolves.

Coverage sits at **97.7%** of statements, including the streaming failure-orchestration paths (deadline, 429, premature close, partial-output) that are usually the blind spot of SDK test suites. The residual ~2% is provably unreachable defensive code (documented in the review record).

## Design record
Expand Down
23 changes: 23 additions & 0 deletions chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,11 @@ func (pc *providerClient) httpError(status int, body []byte) *APIError {
msg = string(b)
}
e.Message, e.Code = msg, code
if e.Status == http.StatusTooManyRequests && billingExhausted(e) {
// Permanent billing/resource exhaustion: never retryable — fail
// fast instead of burning the backoff ladder.
e.Retryable = false
}
return e
}

Expand Down Expand Up @@ -299,6 +304,20 @@ func streamRejected(e *APIError) bool {
return false
}

// billingExhausted reports whether a 429 is really a permanent
// billing/resource failure (e.g. Z.ai "Insufficient balance or no resource
// package", OpenAI "insufficient_quota"). Only a recharge fixes it, so
// running the full backoff ladder is wasted time.
func billingExhausted(e *APIError) bool {
if e == nil || e.Status != http.StatusTooManyRequests {
return false
}
m := strings.ToLower(e.Message)
return strings.Contains(m, "insufficient balance") ||
strings.Contains(m, "insufficient_quota") ||
strings.Contains(m, "no resource package")
}

// retryDelay picks Retry-After when present, else exponential backoff.
func retryDelay(ra time.Duration, attempt int) time.Duration {
if ra > 0 {
Expand Down Expand Up @@ -330,6 +349,10 @@ func (pc *providerClient) call(ctx context.Context, req *ChatRequest, model stri
if errors.As(err, &apiErr) {
switch {
case apiErr.Status == http.StatusTooManyRequests:
if billingExhausted(apiErr) {
// Permanent: only a recharge fixes this.
return nil, apiErr
}
rateErr, rateRA, lastErr = apiErr, ra, apiErr
if attempt < maxRetries {
if !retrySleep(ctx, retryDelay(ra, attempt)) {
Expand Down
51 changes: 51 additions & 0 deletions dispatch_edges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1002,3 +1002,54 @@ func TestListModelsGeminiMidPageError(t *testing.T) {
t.Fatal("mid-page failure must error")
}
}

// ── billing exhaustion: 429 that is really a permanent billing failure ───

// Billing/resource exhaustion signalled as 429 is permanent — the SDK
// must fail fast (1 request) instead of burning the full backoff ladder
// on a condition only the operator can fix. Found live via the Z.ai e2e
// suite ("Insufficient balance or no resource package").
func TestCallBillingExhausted429FailsFast(t *testing.T) {
var n int
srv := httptestNewServer(func(w http.ResponseWriter, r *http.Request) {
n++
w.Header().Set("Retry-After", "0")
w.WriteHeader(429)
fmt.Fprint(w, `{"error":{"message":"Insufficient balance or no resource package. Please recharge."}}`)
})
defer srv.Close()
cc := newTestClient(t, ProviderConfig{ID: "zai", Format: FormatOpenAI, BaseURL: srv.URL, APIKey: "k"}, srv)
_, err := cc.Call(context.Background(), &ChatRequest{Messages: []Message{{Role: RoleUser, Content: "hi"}}})
var ae *APIError
if !errors.As(err, &ae) || ae.Status != http.StatusTooManyRequests {
t.Fatalf("err = %v (%T), want the 429 *APIError", err, err)
}
if ae.Retryable {
t.Error("billing exhaustion must not be marked retryable")
}
if n != 1 {
t.Errorf("requests = %d, want 1 (fail fast, no backoff ladder)", n)
}
}

func TestCallStreamBillingExhausted429FailsFast(t *testing.T) {
var n int
srv := httptestNewServer(func(w http.ResponseWriter, r *http.Request) {
n++
w.Header().Set("Retry-After", "0")
w.WriteHeader(429)
fmt.Fprint(w, `{"error":{"message":"Insufficient balance or no resource package. Please recharge."}}`)
})
defer srv.Close()
cc := newTestClient(t, ProviderConfig{ID: "zai", Format: FormatOpenAI, BaseURL: srv.URL, APIKey: "k"}, srv)
_, err := cc.CallStream(context.Background(), &ChatRequest{Messages: []Message{{Role: RoleUser, Content: "hi"}}}, func(Delta) error {
return nil
})
var ae *APIError
if !errors.As(err, &ae) || ae.Status != http.StatusTooManyRequests {
t.Fatalf("err = %v (%T), want the 429 *APIError", err, err)
}
if n != 1 {
t.Errorf("requests = %d, want 1 (fail fast, no backoff ladder)", n)
}
}
Loading