pi-gemini-oauth-apikey 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/package.json +19 -0
- package/src/config.ts +57 -0
- package/src/failover.ts +95 -0
- package/src/index.ts +96 -0
- package/test/config.test.ts +33 -0
- package/test/failover.test.ts +72 -0
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# pi-gemini-oauth-apikey
|
|
2
|
+
|
|
3
|
+
Multi-account **Google Cloud Code Assist (Gemini CLI) OAuth rotation pool** for
|
|
4
|
+
[pi](https://pi.dev). Register N Google accounts as separate providers and
|
|
5
|
+
auto-rotate on 429/`RESOURCE_EXHAUSTED` — get ~N × 1500 RPD of free Gemini quota.
|
|
6
|
+
|
|
7
|
+
Complements [`pi-key-pool`](https://github.com/ssdiwu/pi-key-pool) (which rotates
|
|
8
|
+
**API keys**): this package rotates **OAuth accounts** (Code Assist tier,
|
|
9
|
+
~6× the per-account RPD, separate quota bucket).
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
- Registers `gemini-pool-1` … `gemini-pool-N` as OAuth providers
|
|
14
|
+
- Each is independently loggable via `/login`
|
|
15
|
+
- On a 429/rate-limit error, marks the current account exhausted, switches to
|
|
16
|
+
the next available one, and **replays your last message automatically**
|
|
17
|
+
- Time-based cooldown recovery (quota 60s, capacity 30s, network skipped)
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
`pi-multi-pass` supports multi-account OAuth rotation but its Gemini login
|
|
22
|
+
(`loginGeminiCli`) was removed from upstream pi, so `/subs login google-gemini-cli-N`
|
|
23
|
+
throws `is not a function`. This package reuses
|
|
24
|
+
[`@qraxiss/pi-gemini-auth`](https://github.com/qraxiss/pi-gemini-auth) (which
|
|
25
|
+
restored that OAuth flow as a standalone package) for the login/provider
|
|
26
|
+
implementation, and adds the rotation layer on top. No pi-core patching.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi install npm:pi-gemini-oauth-apikey
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then restart pi. (You can now uninstall `@qraxiss/pi-gemini-auth` as an active
|
|
35
|
+
extension — this package depends on it as a library instead.)
|
|
36
|
+
|
|
37
|
+
## Configure
|
|
38
|
+
|
|
39
|
+
Optional. Edit `~/.pi/agent/gemini-pool.json` (defaults to 4 slots if absent):
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"accounts": [
|
|
44
|
+
{ "id": "1", "label": "Work Pro" },
|
|
45
|
+
{ "id": "2", "label": "Personal Pro" },
|
|
46
|
+
{ "id": "3", "label": "Side Pro" },
|
|
47
|
+
{ "id": "4", "label": "Backup Pro" }
|
|
48
|
+
],
|
|
49
|
+
"cooldown": { "quota": 60000, "capacity": 30000, "network": 0 }
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Login each account
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
/login → pick "Gemini Pool 1" → browser OAuth (account 1)
|
|
57
|
+
/login → pick "Gemini Pool 2" → account 2
|
|
58
|
+
... (repeat per account)
|
|
59
|
+
/model gemini-pool-1/gemini-2.5-flash
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
That's it. When `gemini-pool-1` hits a 429, the turn transparently retries on
|
|
63
|
+
`gemini-pool-2`, and so on. When all are exhausted you get a warning and a
|
|
64
|
+
cooldown timer.
|
|
65
|
+
|
|
66
|
+
## How it works
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
before_agent_start → remember last user prompt
|
|
70
|
+
agent_end → if stopReason=="error" && isRateLimit(msg):
|
|
71
|
+
markExhausted(current); pickNext() → setModel + sendUserMessage
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`failover.ts` is a pure, unit-tested state machine (round-robin + cooldown +
|
|
75
|
+
auth-gated availability). Provider/OAuth/streaming come from qraxiss.
|
|
76
|
+
|
|
77
|
+
## Tests
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
node --test --experimental-strip-types test/*.test.ts
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Stdlib only (`node:test`) — no test dependencies.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-gemini-oauth-apikey",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Multi-account Google Gemini (Cloud Code Assist OAuth) rotation pool for pi — register N accounts, auto-rotate on 429. Depends on @qraxiss/pi-gemini-auth for the OAuth/provider implementation.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "vforvaick",
|
|
8
|
+
"keywords": ["pi-package", "pi-extension", "gemini", "oauth", "rotation", "pool", "multi-account"],
|
|
9
|
+
"repository": { "type": "git", "url": "git+https://github.com/vforvaick/pi-gemini-oauth-apikey.git" },
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@qraxiss/pi-gemini-auth": "^0.5.1"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test --experimental-strip-types test/*.test.ts"
|
|
15
|
+
},
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": ["./src/index.ts"]
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ponytail: minimal config loader. ~/.pi/agent/gemini-pool.json optional;
|
|
2
|
+
// defaults to 4 slots so it works zero-config for a typical multi-Pro setup.
|
|
3
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export interface Account {
|
|
8
|
+
id: string; // provider id: gemini-pool-<id>
|
|
9
|
+
label: string; // /login display name
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PoolConfig {
|
|
13
|
+
accounts: Account[];
|
|
14
|
+
cooldown?: { capacity?: number; quota?: number; network?: number };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function configPath(): string {
|
|
18
|
+
return join(homedir(), ".pi", "agent", "gemini-pool.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function defaultConfig(): PoolConfig {
|
|
22
|
+
return {
|
|
23
|
+
accounts: [1, 2, 3, 4].map((i) => ({
|
|
24
|
+
id: `${i}`,
|
|
25
|
+
label: `Gemini Pool ${i}`,
|
|
26
|
+
})),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function providerName(account: Account): string {
|
|
31
|
+
return `gemini-pool-${account.id}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function normalize(raw: unknown): PoolConfig {
|
|
35
|
+
const r = (raw ?? {}) as { accounts?: unknown; cooldown?: unknown };
|
|
36
|
+
const accounts: Account[] = Array.isArray(r.accounts)
|
|
37
|
+
? r.accounts
|
|
38
|
+
.map((a, i) => {
|
|
39
|
+
const obj = (a ?? {}) as { id?: string; label?: string };
|
|
40
|
+
const id = String(obj.id ?? i + 1);
|
|
41
|
+
return { id, label: obj.label || `Gemini Pool ${id}` };
|
|
42
|
+
})
|
|
43
|
+
.filter((a) => a.id)
|
|
44
|
+
: [];
|
|
45
|
+
if (accounts.length === 0) return defaultConfig();
|
|
46
|
+
return { accounts, cooldown: (r.cooldown ?? {}) as PoolConfig["cooldown"] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loadConfig(): PoolConfig {
|
|
50
|
+
const path = configPath();
|
|
51
|
+
if (!existsSync(path)) return defaultConfig();
|
|
52
|
+
try {
|
|
53
|
+
return normalize(JSON.parse(readFileSync(path, "utf-8")));
|
|
54
|
+
} catch {
|
|
55
|
+
return defaultConfig(); // malformed → safe default, don't crash pi
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/failover.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// ponytail: pure state machine, zero pi deps, fully unit-testable.
|
|
2
|
+
// Cooldown + round-robin rotation over a fixed member list.
|
|
3
|
+
|
|
4
|
+
export type CooldownReason = "capacity" | "quota" | "network";
|
|
5
|
+
|
|
6
|
+
export interface CooldownConfig {
|
|
7
|
+
capacity: number; // overloaded / 503 / 529 — transient
|
|
8
|
+
quota: number; // 429 / RESOURCE_EXHAUSTED — standard recovery
|
|
9
|
+
network: number; // network errors — don't blame the key (0 = no cooldown)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_COOLDOWN: CooldownConfig = {
|
|
13
|
+
capacity: 30_000,
|
|
14
|
+
quota: 60_000,
|
|
15
|
+
network: 0,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const RATE_LIMIT_PATTERNS = [
|
|
19
|
+
/429/,
|
|
20
|
+
/resource_exhausted/i,
|
|
21
|
+
/quota/i,
|
|
22
|
+
/rate.?limit/i,
|
|
23
|
+
/too many requests/i,
|
|
24
|
+
/overloaded/i,
|
|
25
|
+
/capacity/i,
|
|
26
|
+
/usage.?limit/i,
|
|
27
|
+
/limit.*reached/i,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function isRateLimitError(msg: string): boolean {
|
|
31
|
+
return RATE_LIMIT_PATTERNS.some((p) => p.test(msg));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function classifyError(msg: string): CooldownReason {
|
|
35
|
+
if (/\b(503|529)\b|overloaded|high demand|capacity/i.test(msg)) return "capacity";
|
|
36
|
+
if (/network|timeout|econn|socket/i.test(msg)) return "network";
|
|
37
|
+
return "quota";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FailoverPool {
|
|
41
|
+
members: string[];
|
|
42
|
+
cooldown: CooldownConfig;
|
|
43
|
+
exhausted: Map<string, { reason: CooldownReason; until: number }>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createPool(
|
|
47
|
+
members: string[],
|
|
48
|
+
cooldown: CooldownConfig = DEFAULT_COOLDOWN,
|
|
49
|
+
): FailoverPool {
|
|
50
|
+
return { members, cooldown, exhausted: new Map() };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function markExhausted(
|
|
54
|
+
pool: FailoverPool,
|
|
55
|
+
member: string,
|
|
56
|
+
reason: CooldownReason,
|
|
57
|
+
now: number,
|
|
58
|
+
): void {
|
|
59
|
+
const cd = pool.cooldown[reason];
|
|
60
|
+
if (cd <= 0) return; // network: don't blame the member
|
|
61
|
+
pool.exhausted.set(member, { reason, until: now + cd });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isAvailable(
|
|
65
|
+
pool: FailoverPool,
|
|
66
|
+
member: string,
|
|
67
|
+
now: number,
|
|
68
|
+
hasAuth: boolean,
|
|
69
|
+
): boolean {
|
|
70
|
+
if (!hasAuth) return false;
|
|
71
|
+
const e = pool.exhausted.get(member);
|
|
72
|
+
if (!e) return true;
|
|
73
|
+
if (now >= e.until) {
|
|
74
|
+
pool.exhausted.delete(member); // cooldown elapsed → member recovers
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Round-robin from the member after `current`, first available wins. null if all down.
|
|
81
|
+
export function pickNext(
|
|
82
|
+
pool: FailoverPool,
|
|
83
|
+
current: string | null,
|
|
84
|
+
now: number,
|
|
85
|
+
hasAuthFn: (m: string) => boolean,
|
|
86
|
+
): string | null {
|
|
87
|
+
if (pool.members.length === 0) return null;
|
|
88
|
+
const startIdx = current ? pool.members.indexOf(current) : -1;
|
|
89
|
+
for (let i = 1; i <= pool.members.length; i++) {
|
|
90
|
+
const idx = (startIdx + i) % pool.members.length;
|
|
91
|
+
const m = pool.members[idx];
|
|
92
|
+
if (isAvailable(pool, m, now, hasAuthFn(m))) return m;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// pi-gemini-oauth-apikey: register N Google Cloud Code Assist (Gemini CLI)
|
|
2
|
+
// OAuth accounts as separate providers and auto-rotate on 429.
|
|
3
|
+
//
|
|
4
|
+
// Reuses @qraxiss/pi-gemini-auth for the OAuth/provider implementation (rule 5:
|
|
5
|
+
// already-installed dependency). This package only adds multi-account rotation.
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
GEMINI_CLI_API,
|
|
9
|
+
streamSimpleGoogleGeminiCli,
|
|
10
|
+
} from "@qraxiss/pi-gemini-auth/src/gemini-cli-provider";
|
|
11
|
+
import { geminiOAuthOverride } from "@qraxiss/pi-gemini-auth/src/gemini-oauth";
|
|
12
|
+
import { GEMINI_CLI_MODELS } from "@qraxiss/pi-gemini-auth/src/models";
|
|
13
|
+
import {
|
|
14
|
+
createPool,
|
|
15
|
+
markExhausted,
|
|
16
|
+
pickNext,
|
|
17
|
+
isRateLimitError,
|
|
18
|
+
classifyError,
|
|
19
|
+
DEFAULT_COOLDOWN,
|
|
20
|
+
type CooldownConfig,
|
|
21
|
+
} from "./failover.ts";
|
|
22
|
+
import { loadConfig, providerName, type Account } from "./config.ts";
|
|
23
|
+
|
|
24
|
+
const BASE_URL = "https://cloudcode-pa.googleapis.com";
|
|
25
|
+
|
|
26
|
+
// Clone qraxiss models under a pool provider id (keep id/name/cost etc, swap provider).
|
|
27
|
+
function cloneModelsFor(provider: string) {
|
|
28
|
+
return GEMINI_CLI_MODELS.map((m) => ({ ...m, provider }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function geminiPool(pi: ExtensionAPI) {
|
|
32
|
+
const config = loadConfig();
|
|
33
|
+
if (config.accounts.length === 0) return;
|
|
34
|
+
|
|
35
|
+
const cooldown: CooldownConfig = {
|
|
36
|
+
capacity: config.cooldown?.capacity ?? DEFAULT_COOLDOWN.capacity,
|
|
37
|
+
quota: config.cooldown?.quota ?? DEFAULT_COOLDOWN.quota,
|
|
38
|
+
network: config.cooldown?.network ?? DEFAULT_COOLDOWN.network,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const members = config.accounts.map(providerName);
|
|
42
|
+
const pool = createPool(members, cooldown);
|
|
43
|
+
const memberSet = new Set(members);
|
|
44
|
+
|
|
45
|
+
// Register each account as its own OAuth provider. /login lists them by label.
|
|
46
|
+
for (const account of config.accounts) {
|
|
47
|
+
const name = providerName(account);
|
|
48
|
+
pi.registerProvider(name, {
|
|
49
|
+
name: account.label,
|
|
50
|
+
api: GEMINI_CLI_API,
|
|
51
|
+
baseUrl: BASE_URL,
|
|
52
|
+
oauth: { ...geminiOAuthOverride, name: account.label },
|
|
53
|
+
streamSimple: streamSimpleGoogleGeminiCli,
|
|
54
|
+
models: cloneModelsFor(name),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let lastUserPrompt: string | null = null;
|
|
59
|
+
|
|
60
|
+
pi.on("before_agent_start", async (event: any) => {
|
|
61
|
+
lastUserPrompt = event?.prompt ?? lastUserPrompt;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
pi.on("agent_end", async (event: any, ctx: any) => {
|
|
65
|
+
const messages = event?.messages;
|
|
66
|
+
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
67
|
+
const last = messages[messages.length - 1];
|
|
68
|
+
if (last?.role !== "assistant" || last.stopReason !== "error") return;
|
|
69
|
+
const errorMsg: string = last.errorMessage || "";
|
|
70
|
+
if (!errorMsg || !isRateLimitError(errorMsg)) return;
|
|
71
|
+
|
|
72
|
+
const currentProvider = ctx?.model?.provider;
|
|
73
|
+
if (!currentProvider || !memberSet.has(currentProvider)) return; // not our pool
|
|
74
|
+
|
|
75
|
+
markExhausted(pool, currentProvider, classifyError(errorMsg), Date.now());
|
|
76
|
+
|
|
77
|
+
const hasAuth = (m: string) => ctx.modelRegistry?.authStorage?.hasAuth(m) ?? false;
|
|
78
|
+
const next = pickNext(pool, currentProvider, Date.now(), hasAuth);
|
|
79
|
+
if (!next) {
|
|
80
|
+
ctx?.ui?.notify?.(
|
|
81
|
+
`[gemini-pool] All ${members.length} members rate-limited. Retry in ~${Math.round(
|
|
82
|
+
cooldown.quota / 1000,
|
|
83
|
+
)}s.`,
|
|
84
|
+
"warning",
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const nextModel = ctx?.modelRegistry?.find?.(next, ctx.model?.id);
|
|
90
|
+
if (!nextModel) return;
|
|
91
|
+
const switched = await pi.setModel?.(nextModel);
|
|
92
|
+
if (switched !== false && lastUserPrompt) {
|
|
93
|
+
pi.sendUserMessage?.(lastUserPrompt); // replay the failed turn
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { normalize, defaultConfig, providerName } from "../src/config.ts";
|
|
4
|
+
|
|
5
|
+
test("defaultConfig: 4 accounts, sequential ids, distinct provider names", () => {
|
|
6
|
+
const d = defaultConfig();
|
|
7
|
+
assert.equal(d.accounts.length, 4);
|
|
8
|
+
assert.deepEqual(
|
|
9
|
+
d.accounts.map(providerName),
|
|
10
|
+
["gemini-pool-1", "gemini-pool-2", "gemini-pool-3", "gemini-pool-4"],
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("normalize: explicit accounts preserved with labels", () => {
|
|
15
|
+
const c = normalize({ accounts: [{ id: "work", label: "Work Pro" }, { id: "8" }] });
|
|
16
|
+
assert.equal(c.accounts.length, 2);
|
|
17
|
+
assert.equal(c.accounts[0].label, "Work Pro");
|
|
18
|
+
assert.equal(providerName(c.accounts[1]), "gemini-pool-8");
|
|
19
|
+
// missing label gets generated default
|
|
20
|
+
assert.match(c.accounts[1].label, /Gemini Pool 8/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("normalize: empty/garbage input falls back to default (never zero accounts)", () => {
|
|
24
|
+
assert.equal(normalize({ accounts: [] }).accounts.length, 4);
|
|
25
|
+
assert.equal(normalize({}).accounts.length, 4);
|
|
26
|
+
assert.equal(normalize(null).accounts.length, 4);
|
|
27
|
+
assert.equal(normalize("garbage").accounts.length, 4);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("normalize: cooldown passed through when present", () => {
|
|
31
|
+
const c = normalize({ accounts: [{ id: "1" }], cooldown: { quota: 120000 } });
|
|
32
|
+
assert.equal(c.cooldown?.quota, 120000);
|
|
33
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// ponytail: stdlib only (node:test) — no vitest dep for a ~120-line package.
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import {
|
|
5
|
+
createPool, markExhausted, isAvailable, pickNext,
|
|
6
|
+
isRateLimitError, classifyError, DEFAULT_COOLDOWN,
|
|
7
|
+
} from "../src/failover.ts";
|
|
8
|
+
|
|
9
|
+
const NOW = 1_000_000;
|
|
10
|
+
const has = () => true;
|
|
11
|
+
const hasNot = (_: string) => false;
|
|
12
|
+
|
|
13
|
+
test("isRateLimitError: 429 / quota / RESOURCE_EXHAUSTED → true; unrelated → false", () => {
|
|
14
|
+
assert.equal(isRateLimitError("429 Too Many Requests"), true);
|
|
15
|
+
assert.equal(isRateLimitError("Quota exceeded for metric x"), true);
|
|
16
|
+
assert.equal(isRateLimitError("RESOURCE_EXHAUSTED"), true);
|
|
17
|
+
assert.equal(isRateLimitError("connection refused"), false);
|
|
18
|
+
assert.equal(isRateLimitError(""), false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("classifyError: capacity vs quota vs network", () => {
|
|
22
|
+
assert.equal(classifyError("503 high demand"), "capacity");
|
|
23
|
+
assert.equal(classifyError("overloaded"), "capacity");
|
|
24
|
+
assert.equal(classifyError("ETIMEDOUT network reset"), "network");
|
|
25
|
+
assert.equal(classifyError("429 quota exceeded"), "quota");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("fresh pool: authed member available, unauthed not", () => {
|
|
29
|
+
const p = createPool(["a", "b", "c"]);
|
|
30
|
+
assert.equal(isAvailable(p, "a", NOW, true), true); // member + authed
|
|
31
|
+
assert.equal(isAvailable(p, "a", NOW, false), false); // member but not authed → skip
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("markExhausted makes member unavailable until cooldown elapses", () => {
|
|
35
|
+
const p = createPool(["a", "b"]);
|
|
36
|
+
markExhausted(p, "a", "quota", NOW);
|
|
37
|
+
assert.equal(isAvailable(p, "a", NOW, true), false); // immediately after
|
|
38
|
+
assert.equal(isAvailable(p, "a", NOW + DEFAULT_COOLDOWN.quota - 1, true), false); // just before
|
|
39
|
+
assert.equal(isAvailable(p, "a", NOW + DEFAULT_COOLDOWN.quota, true), true); // at expiry → recovered
|
|
40
|
+
assert.equal(p.exhausted.has("a"), false, "expired entry should be cleared on probe");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("pickNext rotates past current, skipping exhausted and unauthed", () => {
|
|
44
|
+
const p = createPool(["a", "b", "c"]);
|
|
45
|
+
markExhausted(p, "b", "quota", NOW);
|
|
46
|
+
// current=a → next available is c (b exhausted)
|
|
47
|
+
assert.equal(pickNext(p, "a", NOW, has), "c");
|
|
48
|
+
// current=c → wraps to a
|
|
49
|
+
assert.equal(pickNext(p, "c", NOW, has), "a");
|
|
50
|
+
// current=null → first available
|
|
51
|
+
assert.equal(pickNext(p, null, NOW, has), "a");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("pickNext returns null when every member is down", () => {
|
|
55
|
+
const p = createPool(["a", "b"]);
|
|
56
|
+
markExhausted(p, "a", "quota", NOW);
|
|
57
|
+
markExhausted(p, "b", "quota", NOW);
|
|
58
|
+
assert.equal(pickNext(p, "a", NOW, has), null);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("pickNext skips unauthenticated members", () => {
|
|
62
|
+
const p = createPool(["a", "b", "c"]);
|
|
63
|
+
const authedOnly = (m: string) => m === "b";
|
|
64
|
+
assert.equal(pickNext(p, "a", NOW, authedOnly), "b");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("network reason (cooldown 0) never marks exhausted", () => {
|
|
68
|
+
const p = createPool(["a"]);
|
|
69
|
+
markExhausted(p, "a", "network", NOW);
|
|
70
|
+
assert.equal(p.exhausted.has("a"), false);
|
|
71
|
+
assert.equal(isAvailable(p, "a", NOW, true), true);
|
|
72
|
+
});
|