@tokenbuddy/tokenbuddy 1.0.9 → 1.0.12
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/dist/src/buyer-store.d.ts +13 -0
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +21 -2
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +54 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/credit-tracker.d.ts +118 -0
- package/dist/src/credit-tracker.d.ts.map +1 -0
- package/dist/src/credit-tracker.js +220 -0
- package/dist/src/credit-tracker.js.map +1 -0
- package/dist/src/daemon.d.ts +49 -4
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +541 -405
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/model-index.d.ts +86 -0
- package/dist/src/model-index.d.ts.map +1 -0
- package/dist/src/model-index.js +214 -0
- package/dist/src/model-index.js.map +1 -0
- package/dist/src/prewarm-cache.d.ts +149 -0
- package/dist/src/prewarm-cache.d.ts.map +1 -0
- package/dist/src/prewarm-cache.js +288 -0
- package/dist/src/prewarm-cache.js.map +1 -0
- package/dist/src/prewarm-scheduler.d.ts +150 -0
- package/dist/src/prewarm-scheduler.d.ts.map +1 -0
- package/dist/src/prewarm-scheduler.js +484 -0
- package/dist/src/prewarm-scheduler.js.map +1 -0
- package/dist/src/provider-install.d.ts.map +1 -1
- package/dist/src/provider-install.js +10 -0
- package/dist/src/provider-install.js.map +1 -1
- package/dist/src/route-failover.d.ts +96 -0
- package/dist/src/route-failover.d.ts.map +1 -0
- package/dist/src/route-failover.js +177 -0
- package/dist/src/route-failover.js.map +1 -0
- package/dist/src/seller-catalog.d.ts +26 -0
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js +40 -0
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-pool.d.ts +127 -0
- package/dist/src/seller-pool.d.ts.map +1 -0
- package/dist/src/seller-pool.js +243 -0
- package/dist/src/seller-pool.js.map +1 -0
- package/dist/src/stream-failover.d.ts +78 -0
- package/dist/src/stream-failover.d.ts.map +1 -0
- package/dist/src/stream-failover.js +93 -0
- package/dist/src/stream-failover.js.map +1 -0
- package/package.json +1 -1
- package/src/buyer-store.ts +32 -2
- package/src/cli.ts +61 -0
- package/src/credit-tracker.test.ts +165 -0
- package/src/credit-tracker.ts +269 -0
- package/src/daemon.ts +569 -445
- package/src/model-index.test.ts +184 -0
- package/src/model-index.ts +266 -0
- package/src/prewarm-cache.test.ts +281 -0
- package/src/prewarm-cache.ts +373 -0
- package/src/prewarm-scheduler.test.ts +367 -0
- package/src/prewarm-scheduler.ts +581 -0
- package/src/provider-install.ts +10 -0
- package/src/route-failover.test.ts +193 -0
- package/src/route-failover.ts +233 -0
- package/src/seller-catalog-413.test.ts +61 -0
- package/src/seller-catalog.ts +47 -0
- package/src/seller-pool.test.ts +231 -0
- package/src/seller-pool.ts +333 -0
- package/src/stream-failover.test.ts +52 -0
- package/src/stream-failover.ts +129 -0
- package/src/thousand-seller.test.ts +151 -0
- package/tests/daemon-413-fallback.test.ts +92 -0
- package/tests/e2e.test.ts +3 -2
- package/tests/tokenbuddy.test.ts +70 -11
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { CreditTracker } from "../src/credit-tracker.js";
|
|
2
|
+
import { ModelIndex } from "../src/model-index.js";
|
|
3
|
+
import { PrewarmCache } from "../src/prewarm-cache.js";
|
|
4
|
+
import { RouteFailover, type DecideContext } from "../src/route-failover.js";
|
|
5
|
+
import { SellerPool } from "../src/seller-pool.js";
|
|
6
|
+
import type { RegistrySeller } from "../src/seller-catalog.js";
|
|
7
|
+
|
|
8
|
+
function makeSeller(overrides: Partial<RegistrySeller> & { id: string; models?: string[] }): RegistrySeller {
|
|
9
|
+
return {
|
|
10
|
+
id: overrides.id,
|
|
11
|
+
name: overrides.name ?? overrides.id,
|
|
12
|
+
url: overrides.url ?? `https://${overrides.id}.example.com`,
|
|
13
|
+
supportedProtocols: overrides.supportedProtocols ?? ["chat_completions"],
|
|
14
|
+
paymentMethods: overrides.paymentMethods ?? ["clawtip"],
|
|
15
|
+
models: overrides.models
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildHarness(seed: { id: string; healthScore?: number }[] = []) {
|
|
20
|
+
const sellers: RegistrySeller[] = seed.map((s) => makeSeller({ id: s.id, models: ["gpt-4o"] }));
|
|
21
|
+
const index = new ModelIndex();
|
|
22
|
+
index.rebuild(sellers, { registryVersion: 1, defaultSellerId: seed[0]?.id });
|
|
23
|
+
const cache = new PrewarmCache();
|
|
24
|
+
cache.commitWarm({
|
|
25
|
+
modelId: "gpt-4o",
|
|
26
|
+
protocol: "chat_completions",
|
|
27
|
+
paymentMethod: "clawtip",
|
|
28
|
+
candidates: seed.map((s) => ({
|
|
29
|
+
sellerId: s.id,
|
|
30
|
+
url: `https://${s.id}.example.com`,
|
|
31
|
+
healthScore: s.healthScore ?? 80
|
|
32
|
+
}))
|
|
33
|
+
});
|
|
34
|
+
const credit = new CreditTracker();
|
|
35
|
+
const pool = new SellerPool({ modelIndex: index, cache, creditTracker: credit });
|
|
36
|
+
pool.sync();
|
|
37
|
+
const failover = new RouteFailover({ pool, creditTracker: credit });
|
|
38
|
+
return { index, cache, credit, pool, failover, sellers };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("RouteFailover", () => {
|
|
42
|
+
test("pickNext returns the highest-scoring candidate from the pool", () => {
|
|
43
|
+
const { failover } = buildHarness([{ id: "s1", healthScore: 50 }, { id: "s2", healthScore: 90 }, { id: "s3", healthScore: 70 }]);
|
|
44
|
+
const next = failover.pickNext("gpt-4o", "chat_completions", "clawtip");
|
|
45
|
+
expect(next?.sellerId).toBe("s2");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("pickNext returns undefined when the pool is exhausted", () => {
|
|
49
|
+
const index = new ModelIndex();
|
|
50
|
+
index.rebuild([], { registryVersion: 1 });
|
|
51
|
+
const cache = new PrewarmCache();
|
|
52
|
+
const credit = new CreditTracker();
|
|
53
|
+
const pool = new SellerPool({ modelIndex: index, cache, creditTracker: credit });
|
|
54
|
+
pool.sync();
|
|
55
|
+
const failover = new RouteFailover({ pool, creditTracker: credit });
|
|
56
|
+
expect(failover.pickNext("gpt-4o", "chat_completions", "clawtip")).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("decide on hard_4xx immediately returns failover_next and never retries", () => {
|
|
60
|
+
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
61
|
+
credit.recordPurchase("s1", 1_000_000, 800_000);
|
|
62
|
+
const context: DecideContext = {
|
|
63
|
+
sellerId: "s1",
|
|
64
|
+
status: 404,
|
|
65
|
+
errorKind: "hard_4xx",
|
|
66
|
+
errorMessage: "model not found",
|
|
67
|
+
attempt: 0
|
|
68
|
+
};
|
|
69
|
+
const decision = failover.decide(context, 1);
|
|
70
|
+
expect(decision.action).toBe("failover_next");
|
|
71
|
+
expect(decision.reason).toBe("hard_failure:hard_4xx");
|
|
72
|
+
expect(decision.freshPurchase).toBe(true);
|
|
73
|
+
// Hard failure transfers leftover to wasted.
|
|
74
|
+
expect(decision.wastedCreditMicros).toBeGreaterThan(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("decide on auth_invalid opens the circuit and surfaces failover", () => {
|
|
78
|
+
const { failover, pool } = buildHarness([{ id: "s1" }]);
|
|
79
|
+
const context: DecideContext = {
|
|
80
|
+
sellerId: "s1",
|
|
81
|
+
status: 401,
|
|
82
|
+
errorKind: "auth_invalid",
|
|
83
|
+
errorMessage: "token_invalid",
|
|
84
|
+
attempt: 0
|
|
85
|
+
};
|
|
86
|
+
const decision = failover.decide(context, 1);
|
|
87
|
+
expect(decision.action).toBe("failover_next");
|
|
88
|
+
expect(pool.snapshot()[0].circuit).toBe("open");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("decide on soft_5xx inside fresh-purchase window retries the same seller with jitter", () => {
|
|
92
|
+
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
93
|
+
credit.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
94
|
+
const decision = failover.decide(
|
|
95
|
+
{ sellerId: "s1", status: 503, errorKind: "soft_5xx", errorMessage: "upstream", attempt: 0 },
|
|
96
|
+
1
|
|
97
|
+
);
|
|
98
|
+
expect(decision.action).toBe("retry_same_seller");
|
|
99
|
+
expect(decision.retryDelayMs).toBeGreaterThanOrEqual(100);
|
|
100
|
+
expect(decision.retryDelayMs).toBeLessThanOrEqual(400);
|
|
101
|
+
expect(decision.freshPurchase).toBe(true);
|
|
102
|
+
expect(decision.reason).toBe("soft_failure_fresh_purchase_window");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("decide on soft_5xx after exhausting retries returns failover_next", () => {
|
|
106
|
+
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
107
|
+
credit.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
108
|
+
const decision = failover.decide(
|
|
109
|
+
{ sellerId: "s1", status: 503, errorKind: "soft_5xx", errorMessage: "upstream", attempt: 2 },
|
|
110
|
+
1
|
|
111
|
+
);
|
|
112
|
+
expect(decision.action).toBe("failover_next");
|
|
113
|
+
expect(decision.reason).toBe("soft_failure_exhausted_retries");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("decide on soft_5xx outside the fresh-purchase window still allows the first retry", () => {
|
|
117
|
+
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
118
|
+
// Record a purchase, then spend it past the threshold so the window exits.
|
|
119
|
+
credit.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
120
|
+
credit.recordSpend("s1", 100_000);
|
|
121
|
+
const decision = failover.decide(
|
|
122
|
+
{ sellerId: "s1", status: 503, errorKind: "soft_5xx", errorMessage: "upstream", attempt: 0 },
|
|
123
|
+
1
|
|
124
|
+
);
|
|
125
|
+
expect(decision.action).toBe("retry_same_seller");
|
|
126
|
+
expect(decision.freshPurchase).toBe(false);
|
|
127
|
+
expect(decision.reason).toBe("soft_failure_retry");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("decide on deadline uses the soft-error path", () => {
|
|
131
|
+
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
132
|
+
credit.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
133
|
+
const decision = failover.decide(
|
|
134
|
+
{ sellerId: "s1", errorKind: "deadline", errorMessage: "buyer timeout", attempt: 0 },
|
|
135
|
+
1
|
|
136
|
+
);
|
|
137
|
+
expect(decision.action).toBe("retry_same_seller");
|
|
138
|
+
expect(decision.freshPurchase).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("budgetExceeded flag is set when the per-minute purchase budget is exhausted", () => {
|
|
142
|
+
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
143
|
+
// Burn through the per-minute budget.
|
|
144
|
+
credit.recordPurchase("s1", 100, 100);
|
|
145
|
+
credit.recordPurchase("s2" in credit.summary().perSeller ? "s2" : "s1", 100, 100);
|
|
146
|
+
credit.recordPurchase("s1", 100, 100);
|
|
147
|
+
credit.recordPurchase("s1", 100, 100);
|
|
148
|
+
const decision = failover.decide(
|
|
149
|
+
{ sellerId: "s1", errorKind: "soft_5xx", attempt: 0 },
|
|
150
|
+
1
|
|
151
|
+
);
|
|
152
|
+
expect(decision.budgetExceeded).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("recordSuccess resets failure counters and reports to the credit tracker", () => {
|
|
156
|
+
const { failover, credit, pool } = buildHarness([{ id: "s1" }]);
|
|
157
|
+
credit.recordPurchase("s1", 1_000_000, 1_000_000);
|
|
158
|
+
failover.decide(
|
|
159
|
+
{ sellerId: "s1", errorKind: "soft_5xx", attempt: 0 },
|
|
160
|
+
1
|
|
161
|
+
);
|
|
162
|
+
failover.decide(
|
|
163
|
+
{ sellerId: "s1", errorKind: "soft_5xx", attempt: 1 },
|
|
164
|
+
1
|
|
165
|
+
);
|
|
166
|
+
failover.recordSuccess("s1", 750_000);
|
|
167
|
+
const entry = pool.snapshot()[0];
|
|
168
|
+
expect(entry.consecutiveFailures).toBe(0);
|
|
169
|
+
expect(credit.getEntry("s1")?.currentBalanceMicros).toBe(750_000);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("shouldAbort returns true when no more candidates are available", () => {
|
|
173
|
+
const { failover } = buildHarness([{ id: "s1" }]);
|
|
174
|
+
expect(failover.shouldAbort(1, false)).toBe(true);
|
|
175
|
+
expect(failover.shouldAbort(1, true)).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("canAutoPurchase mirrors the credit tracker budget", () => {
|
|
179
|
+
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
180
|
+
expect(failover.canAutoPurchase()).toBe(true);
|
|
181
|
+
credit.recordPurchase("s1", 100, 100);
|
|
182
|
+
credit.recordPurchase("s1", 100, 100);
|
|
183
|
+
credit.recordPurchase("s1", 100, 100);
|
|
184
|
+
expect(failover.canAutoPurchase()).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("wastedMicrosFor returns the latest known balance for a seller", () => {
|
|
188
|
+
const { failover, credit } = buildHarness([{ id: "s1" }]);
|
|
189
|
+
expect(failover.wastedMicrosFor("s1")).toBe(0);
|
|
190
|
+
credit.recordPurchase("s1", 1_000_000, 500_000);
|
|
191
|
+
expect(failover.wastedMicrosFor("s1")).toBe(500_000);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createModuleLogger } from "@tokenbuddy/logging";
|
|
2
|
+
import type { RegistrySeller } from "./seller-catalog.js";
|
|
3
|
+
import type { SellerPool, FailureKind, PoolEntry } from "./seller-pool.js";
|
|
4
|
+
import type { CreditTracker } from "./credit-tracker.js";
|
|
5
|
+
|
|
6
|
+
const logger = createModuleLogger("tb-proxyd:route-failover");
|
|
7
|
+
|
|
8
|
+
export type RouteAction = "retry_same_seller" | "failover_next" | "fail_fast" | "abort";
|
|
9
|
+
|
|
10
|
+
export interface FailoverDecision {
|
|
11
|
+
action: RouteAction;
|
|
12
|
+
retryDelayMs?: number;
|
|
13
|
+
reason: string;
|
|
14
|
+
wastedCreditMicros?: number;
|
|
15
|
+
freshPurchase: boolean;
|
|
16
|
+
retryAttemptsBeforeFailover: number;
|
|
17
|
+
budgetExceeded: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RouteCandidate {
|
|
21
|
+
routeIndex: number;
|
|
22
|
+
entry: PoolEntry;
|
|
23
|
+
registrySeller: RegistrySeller;
|
|
24
|
+
sellerId: string;
|
|
25
|
+
url: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DecideContext {
|
|
29
|
+
sellerId: string;
|
|
30
|
+
status?: number;
|
|
31
|
+
errorKind: FailureKind;
|
|
32
|
+
errorMessage?: string;
|
|
33
|
+
attempt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RouteFailoverOptions {
|
|
37
|
+
pool: SellerPool;
|
|
38
|
+
creditTracker: CreditTracker;
|
|
39
|
+
// v1.1 / v1.2 design defaults; exposed for tests.
|
|
40
|
+
softRetryAttempts?: number; // default 2
|
|
41
|
+
softRetryJitterMinMs?: number; // default 100
|
|
42
|
+
softRetryJitterMaxMs?: number; // default 400
|
|
43
|
+
random?: () => number;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULTS = {
|
|
48
|
+
softRetryAttempts: 2,
|
|
49
|
+
softRetryJitterMinMs: 100,
|
|
50
|
+
softRetryJitterMaxMs: 400
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Decision engine that wraps the v2 `SellerPool` and the
|
|
55
|
+
* `CreditTracker`. v1.2 §17.3 - §17.8 defines the rules: the controller
|
|
56
|
+
* calls `decide()` after a failure to learn whether to retry the same
|
|
57
|
+
* seller, fail over to the next candidate, fail fast, or give up.
|
|
58
|
+
*
|
|
59
|
+
* The controller is intentionally side-effect-light: it delegates
|
|
60
|
+
* bookkeeping (circuit transitions, wasted-credit transfer) to the pool
|
|
61
|
+
* and tracker via `recordFailure`, and only returns a structured
|
|
62
|
+
* decision. This makes the rules unit-testable without a live HTTP server.
|
|
63
|
+
*/
|
|
64
|
+
export class RouteFailover {
|
|
65
|
+
private readonly pool: SellerPool;
|
|
66
|
+
private readonly creditTracker: CreditTracker;
|
|
67
|
+
private readonly softRetryAttempts: number;
|
|
68
|
+
private readonly softRetryJitterMinMs: number;
|
|
69
|
+
private readonly softRetryJitterMaxMs: number;
|
|
70
|
+
private readonly random: () => number;
|
|
71
|
+
private readonly now: () => number;
|
|
72
|
+
|
|
73
|
+
constructor(options: RouteFailoverOptions) {
|
|
74
|
+
this.pool = options.pool;
|
|
75
|
+
this.creditTracker = options.creditTracker;
|
|
76
|
+
this.softRetryAttempts = options.softRetryAttempts ?? DEFAULTS.softRetryAttempts;
|
|
77
|
+
this.softRetryJitterMinMs = options.softRetryJitterMinMs ?? DEFAULTS.softRetryJitterMinMs;
|
|
78
|
+
this.softRetryJitterMaxMs = options.softRetryJitterMaxMs ?? DEFAULTS.softRetryJitterMaxMs;
|
|
79
|
+
this.random = options.random ?? Math.random;
|
|
80
|
+
this.now = options.now ?? Date.now;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Pick the next candidate from the pool. Returns `undefined` when the
|
|
85
|
+
* pool is exhausted, which the caller treats as `abort` (no more routes
|
|
86
|
+
* to try). The returned `routeIndex` is informational (used for log
|
|
87
|
+
* lines) and tracks the absolute ordering across attempts; the controller
|
|
88
|
+
* maintains its own counter.
|
|
89
|
+
*/
|
|
90
|
+
pickNext(modelId: string, protocol: string, paymentMethod: string, limit: number = 4): RouteCandidate | undefined {
|
|
91
|
+
const result = this.pool.pick({ modelId, protocol, paymentMethod, limit });
|
|
92
|
+
if (result.candidates.length === 0) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const first = result.candidates[0];
|
|
96
|
+
return {
|
|
97
|
+
routeIndex: 0,
|
|
98
|
+
entry: first.entry,
|
|
99
|
+
registrySeller: first.registrySeller,
|
|
100
|
+
sellerId: first.entry.sellerId,
|
|
101
|
+
url: first.entry.url
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Mark a successful inference against `sellerId` with the latest known
|
|
107
|
+
* balance. Mirrors `SellerPool.recordSuccess` semantics: resets the
|
|
108
|
+
* failure counter, closes the circuit, and reports the spend to the
|
|
109
|
+
* credit tracker.
|
|
110
|
+
*/
|
|
111
|
+
recordSuccess(sellerId: string, balanceMicros: number): void {
|
|
112
|
+
this.pool.recordSuccess(sellerId, balanceMicros, this.now());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Decide what to do after a failure. Returns a structured
|
|
117
|
+
* `FailoverDecision`; the controller (daemon.ts) executes the action.
|
|
118
|
+
* The decision also drives accounting: on a failover, the wasted
|
|
119
|
+
* balance is reported in `wastedCreditMicros` for the doctor's summary.
|
|
120
|
+
*/
|
|
121
|
+
decide(context: DecideContext, totalCandidates: number): FailoverDecision {
|
|
122
|
+
const isHard = context.errorKind === "hard_4xx" || context.errorKind === "auth_invalid" || context.errorKind === "no_compatible";
|
|
123
|
+
const isSoft = context.errorKind === "soft_5xx" || context.errorKind === "deadline";
|
|
124
|
+
const info = this.pool.inspect(context.sellerId);
|
|
125
|
+
const freshPurchase = info.freshPurchase;
|
|
126
|
+
const budgetExceeded = !this.creditTracker.canAutoPurchase(this.now());
|
|
127
|
+
|
|
128
|
+
// Always mark the failure on the pool so circuit thresholds advance.
|
|
129
|
+
const updated = this.pool.recordFailure(context.sellerId, context.errorKind, {
|
|
130
|
+
reason: context.errorMessage,
|
|
131
|
+
now: this.now()
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (isHard) {
|
|
135
|
+
// Hard failures are not eligible for retry; the seller is wrong
|
|
136
|
+
// for this request. The pool has already transferred leftover
|
|
137
|
+
// credit to the wasted bucket.
|
|
138
|
+
return {
|
|
139
|
+
action: "failover_next",
|
|
140
|
+
reason: `hard_failure:${context.errorKind}`,
|
|
141
|
+
wastedCreditMicros: this.creditTracker.getEntry(context.sellerId)?.leftoverCreditMicros,
|
|
142
|
+
freshPurchase,
|
|
143
|
+
retryAttemptsBeforeFailover: context.attempt,
|
|
144
|
+
budgetExceeded
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isSoft && freshPurchase && context.attempt < this.softRetryAttempts) {
|
|
149
|
+
// Soft failure inside the fresh-purchase window: retry the same
|
|
150
|
+
// seller with jittered backoff. This is the v1.1 protection against
|
|
151
|
+
// throwing freshly bought credit onto a flaky upstream.
|
|
152
|
+
const span = Math.max(0, this.softRetryJitterMaxMs - this.softRetryJitterMinMs);
|
|
153
|
+
const delay = this.softRetryJitterMinMs + Math.floor(this.random() * span);
|
|
154
|
+
logger.info("route.retry_same_seller.soft", "soft failure in fresh-purchase window; retrying same seller", {
|
|
155
|
+
sellerId: context.sellerId,
|
|
156
|
+
attempt: context.attempt,
|
|
157
|
+
delayMs: delay
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
action: "retry_same_seller",
|
|
161
|
+
retryDelayMs: delay,
|
|
162
|
+
reason: "soft_failure_fresh_purchase_window",
|
|
163
|
+
freshPurchase: true,
|
|
164
|
+
retryAttemptsBeforeFailover: context.attempt,
|
|
165
|
+
budgetExceeded
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (isSoft && context.attempt < this.softRetryAttempts) {
|
|
170
|
+
// Soft failure but not in the fresh-purchase window: still retry
|
|
171
|
+
// once (caller asked for `attempt < softRetryAttempts`) but log the
|
|
172
|
+
// reason. This keeps the "give the seller a second chance" window
|
|
173
|
+
// available for cold sellers without burning any credit.
|
|
174
|
+
const span = Math.max(0, this.softRetryJitterMaxMs - this.softRetryJitterMinMs);
|
|
175
|
+
const delay = this.softRetryJitterMinMs + Math.floor(this.random() * span);
|
|
176
|
+
logger.info("route.retry_same_seller.soft", "soft failure retry outside fresh-purchase window", {
|
|
177
|
+
sellerId: context.sellerId,
|
|
178
|
+
attempt: context.attempt,
|
|
179
|
+
delayMs: delay
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
action: "retry_same_seller",
|
|
183
|
+
retryDelayMs: delay,
|
|
184
|
+
reason: "soft_failure_retry",
|
|
185
|
+
freshPurchase: false,
|
|
186
|
+
retryAttemptsBeforeFailover: context.attempt,
|
|
187
|
+
budgetExceeded
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Default: fail over. The wasted credit is whatever current balance
|
|
192
|
+
// remained on the seller after the failure was recorded (the pool
|
|
193
|
+
// transfers it to the leftover bucket for hard failures; for soft
|
|
194
|
+
// failures we surface the *pre-failure* balance so the user sees the
|
|
195
|
+
// impact of the cut-over).
|
|
196
|
+
const wasted = updated?.lastFailAt
|
|
197
|
+
? this.creditTracker.getEntry(context.sellerId)?.currentBalanceMicros
|
|
198
|
+
: undefined;
|
|
199
|
+
return {
|
|
200
|
+
action: "failover_next",
|
|
201
|
+
reason: isSoft ? "soft_failure_exhausted_retries" : `${context.errorKind}`,
|
|
202
|
+
wastedCreditMicros: wasted,
|
|
203
|
+
freshPurchase,
|
|
204
|
+
retryAttemptsBeforeFailover: context.attempt,
|
|
205
|
+
budgetExceeded
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Convenience: returns true when the next request should be aborted
|
|
211
|
+
* outright (no more candidates).
|
|
212
|
+
*/
|
|
213
|
+
shouldAbort(totalCandidates: number, hasMoreCandidates: boolean): boolean {
|
|
214
|
+
return !hasMoreCandidates;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Inspect the auto-purchase budget. The controller calls this before
|
|
219
|
+
* triggering a fresh `purchase/create` round-trip so it can refuse to
|
|
220
|
+
* auto-purchase once the session is at risk.
|
|
221
|
+
*/
|
|
222
|
+
canAutoPurchase(): boolean {
|
|
223
|
+
return this.creditTracker.canAutoPurchase(this.now());
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Surface the wasted credit so far for the route currently failing over.
|
|
228
|
+
* Returned in micros.
|
|
229
|
+
*/
|
|
230
|
+
wastedMicrosFor(sellerId: string): number {
|
|
231
|
+
return this.creditTracker.getEntry(sellerId)?.currentBalanceMicros ?? 0;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { RegistryTooLargeError, fetchSellerRegistry } from "../src/seller-catalog.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* v1.2 §18.9: registry-too-large fallback. The bootstrap returns 413
|
|
5
|
+
* with a structured body when the serialized registry exceeds 1MB; the
|
|
6
|
+
* buyer must surface this as a typed error so the daemon can fall back
|
|
7
|
+
* to the last-known snapshot.
|
|
8
|
+
*/
|
|
9
|
+
describe("RegistryTooLargeError + fetchSellerRegistry 413", () => {
|
|
10
|
+
const originalFetch = globalThis.fetch;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
globalThis.fetch = originalFetch;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function mockJsonResponse(status: number, body: unknown): Response {
|
|
17
|
+
return new Response(JSON.stringify(body), {
|
|
18
|
+
status,
|
|
19
|
+
headers: { "Content-Type": "application/json" }
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("fetchSellerRegistry throws RegistryTooLargeError on HTTP 413 with the bootstrap's payload", async () => {
|
|
24
|
+
globalThis.fetch = (async () =>
|
|
25
|
+
mockJsonResponse(413, {
|
|
26
|
+
error: "registry_too_large",
|
|
27
|
+
sizeBytes: 1_500_000,
|
|
28
|
+
sellerCount: 2_400,
|
|
29
|
+
maxBytes: 1_000_000
|
|
30
|
+
})) as unknown as typeof globalThis.fetch;
|
|
31
|
+
await expect(
|
|
32
|
+
fetchSellerRegistry("https://bootstrap.example/registry/sellers")
|
|
33
|
+
).rejects.toMatchObject({
|
|
34
|
+
name: "RegistryTooLargeError",
|
|
35
|
+
status: 413,
|
|
36
|
+
sizeBytes: 1_500_000,
|
|
37
|
+
sellerCount: 2_400,
|
|
38
|
+
maxBytes: 1_000_000
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("fetchSellerRegistry still surfaces generic non-2xx as plain errors", async () => {
|
|
43
|
+
globalThis.fetch = (async () => mockJsonResponse(500, { error: "internal" })) as unknown as typeof globalThis.fetch;
|
|
44
|
+
await expect(
|
|
45
|
+
fetchSellerRegistry("https://bootstrap.example/registry/sellers")
|
|
46
|
+
).rejects.toThrow(/registry returned 500/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("RegistryTooLargeError is a stable Error subclass with a useful message", () => {
|
|
50
|
+
const err = new RegistryTooLargeError({
|
|
51
|
+
status: 413,
|
|
52
|
+
sizeBytes: 2_500_000,
|
|
53
|
+
sellerCount: 1500,
|
|
54
|
+
maxBytes: 1_000_000
|
|
55
|
+
});
|
|
56
|
+
expect(err).toBeInstanceOf(Error);
|
|
57
|
+
expect(err.name).toBe("RegistryTooLargeError");
|
|
58
|
+
expect(err.message).toContain("1500 sellers");
|
|
59
|
+
expect(err.message).toContain("2500000");
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/seller-catalog.ts
CHANGED
|
@@ -10,6 +10,13 @@ export interface RegistrySeller {
|
|
|
10
10
|
url: string;
|
|
11
11
|
supportedProtocols?: string[];
|
|
12
12
|
paymentMethods?: string[];
|
|
13
|
+
/**
|
|
14
|
+
* v1.2: authoritative model list for buyer-side model-index routing.
|
|
15
|
+
* Optional at the buyer boundary for backward compatibility with older
|
|
16
|
+
* registry payloads, but the upstream wallet-bootstrap registry schema
|
|
17
|
+
* requires it. Missing entries are reported via `models_refresh.seller_missing_models`.
|
|
18
|
+
*/
|
|
19
|
+
models?: string[];
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
export interface SellerRegistryDocument {
|
|
@@ -111,8 +118,48 @@ function manifestModels(manifest: SellerManifest): ManifestModelRecord[] {
|
|
|
111
118
|
.filter((model): model is ManifestModelRecord => Boolean(model?.id && typeof model.id === "string"));
|
|
112
119
|
}
|
|
113
120
|
|
|
121
|
+
/**
|
|
122
|
+
* v1.2 §18.9: thrown when the bootstrap's `/registry/sellers` endpoint
|
|
123
|
+
* returns HTTP 413 with the `X-TokenBuddy-Registry-Too-Large: 1` header.
|
|
124
|
+
* The daemon catches this in `TokenbuddyDaemon.fetchRegistry` and falls
|
|
125
|
+
* back to the last-known snapshot so the buyer stays routable while the
|
|
126
|
+
* operator shrinks the registry below the 1MB cap.
|
|
127
|
+
*/
|
|
128
|
+
export class RegistryTooLargeError extends Error {
|
|
129
|
+
readonly status: number;
|
|
130
|
+
readonly sizeBytes: number;
|
|
131
|
+
readonly sellerCount: number;
|
|
132
|
+
readonly maxBytes: number;
|
|
133
|
+
constructor(detail: { status: number; sizeBytes: number; sellerCount: number; maxBytes: number }) {
|
|
134
|
+
super(`registry response exceeds ${detail.maxBytes} bytes (got ${detail.sizeBytes}, ${detail.sellerCount} sellers, status ${detail.status})`);
|
|
135
|
+
this.name = "RegistryTooLargeError";
|
|
136
|
+
this.status = detail.status;
|
|
137
|
+
this.sizeBytes = detail.sizeBytes;
|
|
138
|
+
this.sellerCount = detail.sellerCount;
|
|
139
|
+
this.maxBytes = detail.maxBytes;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
114
143
|
export async function fetchSellerRegistry(registryUrl: string): Promise<SellerRegistryDocument> {
|
|
115
144
|
const response = await fetch(registryUrl);
|
|
145
|
+
if (response.status === 413) {
|
|
146
|
+
// v1.2 §18.9: parse the structured 413 body so the caller can
|
|
147
|
+
// decide whether to fall back to a stale snapshot.
|
|
148
|
+
let sizeBytes = 0;
|
|
149
|
+
let sellerCount = 0;
|
|
150
|
+
let maxBytes = 0;
|
|
151
|
+
try {
|
|
152
|
+
const body = (await response.json()) as { sizeBytes?: number; sellerCount?: number; maxBytes?: number };
|
|
153
|
+
sizeBytes = body.sizeBytes ?? 0;
|
|
154
|
+
sellerCount = body.sellerCount ?? 0;
|
|
155
|
+
maxBytes = body.maxBytes ?? 0;
|
|
156
|
+
} catch {
|
|
157
|
+
// Fall through with zeroes; the error message still carries the
|
|
158
|
+
// status code which is enough for the daemon's stale-cache
|
|
159
|
+
// branch to fire.
|
|
160
|
+
}
|
|
161
|
+
throw new RegistryTooLargeError({ status: response.status, sizeBytes, sellerCount, maxBytes });
|
|
162
|
+
}
|
|
116
163
|
if (!response.ok) {
|
|
117
164
|
throw new Error(`registry returned ${response.status}`);
|
|
118
165
|
}
|