Files
magpie/quota_wait_test.go
T
yetone 9db0d155bc cli: magpie quota wait <provider|account> blocks until that subscription (any of its accounts) or that account has allowance again, then exits 0 (#720, thedavidweng: a long Codex task stopped when every account ran out, and nobody came back to it hours later; step 1 of the maintainer's plan, for until codex exec …; do magpie quota wait codex || break; done) — it reads the vendors itself as magpie quota does, gateway running or not (provider.Allotted: the subscriptions' and plans' readings, no key balances; AskUsage has each look wait for a new reading, and only a wait for Claude runs Claude Code's /usage, through AskClaudeUsage, which now calls it). An account has allowance when routing's usedUp says so (no window that stops it, every model, at 100%; exported as provider.UsedUp), and BackAt is when the last of those windows starts again. While all are used up it sleeps to the soonest reset plus 30s, at least a minute and at most ten, saying on stderr what it waits for and until when; an allowance it can't read (an error, or only the last reading kept in its place) is asked again after 1, 2, 4, 8 then every 10 minutes. A provider's accounts switched off or signed out in magpie are left out, as the agent is never moved to them; an account is named by its user, or <provider>/<user> when two subscriptions share it. --timeout <duration> exits 1 when it passes first, --quiet says nothing, an unknown name or flag exits 2 (main now honours an exitError's code) and Ctrl+C 130. The loop takes its reading, clock and sleep as functions: quota_wait_test.go runs it on a fake clock (already available → no sleep; used up → 10m, 10m, then to just past the reset; one account named ignores another's room; a reset under a minute away is a minute; --timeout ends at the deadline after its last look; unreadable usage backs off and the wait goes on once read; an untold reset is looked at every 10 minutes; an off account is passed over; Ctrl+C), plus resolving names and the usage errors. Help in magpie quota's and magpie's usage, and the README. go vet, builds (darwin, GOOS=linux, GOOS=windows), the new tests and ./internal/provider pass; not run against real accounts.
2026-10-04 06:12:51 +08:00

247 lines
8.8 KiB
Go

package main
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
// fakeWait runs quotaWait on a clock that moves only when it sleeps, and
// reads what at says for the time it is.
type fakeWait struct {
now time.Time
slept []time.Duration
reads int
said []string
at func(now time.Time) []provider.SubscriptionQuota
cancel func() // called on the nth sleep, when set
cancelAt int
}
func (f *fakeWait) wait() quotaWait {
return quotaWait{
read: func(context.Context) []provider.SubscriptionQuota { f.reads++; return f.at(f.now) },
now: func() time.Time { return f.now },
sleep: func(ctx context.Context, d time.Duration) error {
f.slept = append(f.slept, d)
if f.cancel != nil && len(f.slept) == f.cancelAt {
f.cancel()
return ctx.Err()
}
f.now = f.now.Add(d)
return nil
},
say: func(s string) { f.said = append(f.said, s) },
}
}
func codexAt(user string, used float64, reset time.Time) provider.SubscriptionQuota {
r := reset
return provider.SubscriptionQuota{Provider: "codex", Name: "Codex", User: user, Windows: []provider.QuotaWindow{
{Name: "5 hours", Used: 20, ResetsAt: &r},
{Name: "weekly", Used: used, ResetsAt: &r},
// on-demand spending used up stops nothing
{Name: "credits", Used: 100, Aside: true},
}}
}
var t0 = time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
// An account with room: back at once, nothing slept.
func TestQuotaWaitAlreadyAvailable(t *testing.T) {
f := &fakeWait{now: t0, at: func(time.Time) []provider.SubscriptionQuota {
return []provider.SubscriptionQuota{codexAt("a@x", 100, t0.Add(time.Hour)), codexAt("b@x", 97, t0.Add(time.Hour))}
}}
q, err := f.wait().run(context.Background(), quotaTarget{provider: "codex", label: "codex"}, 0)
if err != nil || q.User != "b@x" || len(f.slept) != 0 {
t.Fatalf("got %q %v, slept %v", q.User, err, f.slept)
}
}
// Every account used up: it sleeps to just past the soonest reset, in
// steps of at most ten minutes, and returns the account that came back.
func TestQuotaWaitUntilReset(t *testing.T) {
resetA, resetB := t0.Add(25*time.Minute), t0.Add(3*time.Hour)
f := &fakeWait{now: t0}
f.at = func(now time.Time) []provider.SubscriptionQuota {
a := codexAt("a@x", 100, resetA)
if !now.Before(resetA) {
a = codexAt("a@x", 0, resetA.Add(7*24*time.Hour))
}
return []provider.SubscriptionQuota{codexAt("b@x", 100, resetB), a}
}
q, err := f.wait().run(context.Background(), quotaTarget{provider: "codex", label: "codex"}, 0)
if err != nil || q.User != "a@x" {
t.Fatalf("got %q %v", q.User, err)
}
want := []time.Duration{10 * time.Minute, 10 * time.Minute, 5*time.Minute + quotaWaitMargin}
if len(f.slept) != len(want) {
t.Fatalf("slept %v, want %v", f.slept, want)
}
for i := range want {
if f.slept[i] != want[i] {
t.Fatalf("slept %v, want %v", f.slept, want)
}
}
if len(f.said) != 3 || !strings.Contains(f.said[0], "codex (a@x first) is used up until") {
t.Errorf("said %q", f.said)
}
}
// One account named: another's room doesn't end the wait, and a reset
// under a minute away is still a minute's sleep.
func TestQuotaWaitOneAccount(t *testing.T) {
reset := t0.Add(10 * time.Second)
f := &fakeWait{now: t0}
f.at = func(now time.Time) []provider.SubscriptionQuota {
a := codexAt("a@x", 100, reset)
if now.After(reset) {
a = codexAt("a@x", 5, reset.Add(time.Hour))
}
return []provider.SubscriptionQuota{codexAt("b@x", 0, reset), a}
}
q, err := f.wait().run(context.Background(), quotaTarget{provider: "codex", user: "A@x", label: "codex · a@x"}, 0)
if err != nil || q.User != "a@x" || len(f.slept) != 1 || f.slept[0] != quotaWaitMin {
t.Fatalf("got %q %v, slept %v", q.User, err, f.slept)
}
}
// --timeout: the last look is at the deadline, then errWaitTimeout.
func TestQuotaWaitTimeout(t *testing.T) {
f := &fakeWait{now: t0, at: func(time.Time) []provider.SubscriptionQuota {
return []provider.SubscriptionQuota{codexAt("a@x", 100, t0.Add(5*time.Hour))}
}}
_, err := f.wait().run(context.Background(), quotaTarget{provider: "codex", label: "codex"}, 25*time.Minute)
if !errors.Is(err, errWaitTimeout) {
t.Fatalf("err %v", err)
}
if f.now != t0.Add(25*time.Minute) || f.reads != 4 {
t.Fatalf("ended at %v after %d reads, slept %v", f.now.Sub(t0), f.reads, f.slept)
}
}
// Usage that can't be read is asked again, less often each time up to
// ten minutes, and the wait goes on once it reads.
func TestQuotaWaitUnknownRetries(t *testing.T) {
f := &fakeWait{now: t0}
f.at = func(now time.Time) []provider.SubscriptionQuota {
switch {
case now.Sub(t0) < 20*time.Minute:
return []provider.SubscriptionQuota{{Provider: "codex", User: "a@x", Error: "HTTP 503"}}
case now.Sub(t0) < 40*time.Minute:
// the last reading kept in its place says nothing of now
at := t0
q := codexAt("a@x", 0, t0)
q.AsOf = &at
return []provider.SubscriptionQuota{q}
}
return []provider.SubscriptionQuota{codexAt("a@x", 50, t0.Add(time.Hour))}
}
q, err := f.wait().run(context.Background(), quotaTarget{provider: "codex", label: "codex"}, 0)
if err != nil || q.User != "a@x" {
t.Fatalf("got %q %v", q.User, err)
}
want := []time.Duration{time.Minute, 2 * time.Minute, 4 * time.Minute, 8 * time.Minute, 10 * time.Minute, 10 * time.Minute, 10 * time.Minute}
if len(f.slept) != len(want) {
t.Fatalf("slept %v, want %v", f.slept, want)
}
for i := range want {
if f.slept[i] != want[i] {
t.Fatalf("slept %v, want %v", f.slept, want)
}
}
if !strings.Contains(f.said[0], "couldn't be read (HTTP 503)") {
t.Errorf("said %q", f.said[0])
}
}
// A reset that isn't told: looked at again every ten minutes.
func TestQuotaWaitResetUntold(t *testing.T) {
f := &fakeWait{now: t0}
f.at = func(now time.Time) []provider.SubscriptionQuota {
q := provider.SubscriptionQuota{Provider: "zcode", User: "u", Windows: []provider.QuotaWindow{{Name: "day", Used: 100}}}
if now.Sub(t0) >= 20*time.Minute {
q.Windows[0].Used = 0
}
return []provider.SubscriptionQuota{q}
}
_, err := f.wait().run(context.Background(), quotaTarget{provider: "zcode", label: "zcode"}, 0)
if err != nil || len(f.slept) != 2 || f.slept[0] != quotaWaitMax {
t.Fatalf("%v, slept %v", err, f.slept)
}
}
// An account switched off in magpie is passed over when waiting on its
// provider: its room would never be used.
func TestQuotaWaitSkipsOff(t *testing.T) {
f := &fakeWait{now: t0, at: func(now time.Time) []provider.SubscriptionQuota {
a := codexAt("a@x", 100, t0.Add(time.Minute))
if now.After(t0) {
a = codexAt("a@x", 0, t0.Add(time.Hour))
}
return []provider.SubscriptionQuota{codexAt("off@x", 0, t0), a}
}}
w := f.wait()
w.skip = func(q provider.SubscriptionQuota) bool { return q.User == "off@x" }
q, err := w.run(context.Background(), quotaTarget{provider: "codex", label: "codex"}, 0)
if err != nil || q.User != "a@x" || len(f.slept) != 1 {
t.Fatalf("got %q %v, slept %v", q.User, err, f.slept)
}
}
// Ctrl+C mid-sleep ends the wait with the context's error.
func TestQuotaWaitCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
f := &fakeWait{now: t0, cancel: cancel, cancelAt: 2, at: func(time.Time) []provider.SubscriptionQuota {
return []provider.SubscriptionQuota{codexAt("a@x", 100, t0.Add(time.Hour))}
}}
_, err := f.wait().run(ctx, quotaTarget{provider: "codex", label: "codex"}, 0)
if !errors.Is(err, context.Canceled) {
t.Fatalf("err %v", err)
}
}
func TestResolveQuotaTarget(t *testing.T) {
qs := []provider.SubscriptionQuota{
codexAt("a@x", 0, t0), codexAt("b@x", 0, t0),
{Provider: "claude", Name: "Claude Code", User: "a@x"},
{Provider: "zcode", Name: "ZCode", User: "solo@y"},
}
known := func(s string) bool { return s == "kimi" }
for _, c := range []struct{ arg, provider, user, err string }{
{arg: "codex", provider: "codex"},
{arg: "Claude Code", provider: "claude"},
{arg: "solo@y", provider: "zcode", user: "solo@y"},
{arg: "codex/B@x", provider: "codex", user: "B@x"},
{arg: "a@x", err: "codex/a@x, claude/a@x"},
{arg: "codex/z@x", err: "codex has no account z@x"},
{arg: "kimi", err: "no subscription or plan whose allowance"},
{arg: "nope", err: "no subscription, plan or account nope"},
} {
got, err := resolveQuotaTarget(c.arg, qs, known)
if c.err != "" {
if err == nil || !strings.Contains(err.Error(), c.err) {
t.Errorf("%s: %v, want %q", c.arg, err, c.err)
}
continue
}
if err != nil || got.provider != c.provider || got.user != c.user {
t.Errorf("%s: %+v %v", c.arg, got, err)
}
}
}
// Unknown names and flags are exit code 2, before anything is read.
func TestQuotaWaitCmdUsageErrors(t *testing.T) {
for _, args := range [][]string{{}, {"--timeout", "soon", "codex"}, {"--frob", "codex"}, {"codex", "claude"}} {
var e exitError
if err := quotaWaitCmd(args); !errors.As(err, &e) || e.code != 2 {
t.Errorf("%q: %v", args, err)
}
}
}