fix: fail closed across Surplus seller state
This commit is contained in:
@@ -156,4 +156,6 @@ SURPLUS_MAX_CANDIDATE_ORDER_BOOKS=10
|
||||
SURPLUS_REPRICE_INTERVAL_MS=300000
|
||||
SURPLUS_REQUEST_TIMEOUT_MS=120000
|
||||
SURPLUS_STOP_AFTER_SETTLED_REQUESTS=1
|
||||
# Leave empty until a live earnings record establishes the terminal status.
|
||||
SURPLUS_SETTLED_STATUSES=
|
||||
SURPLUS_STATE_PATH=/var/lib/surplus-manager/state.json
|
||||
|
||||
@@ -311,9 +311,7 @@ export class SurplusClient {
|
||||
const text = await readBoundedText(response, controller);
|
||||
if (!response.ok) {
|
||||
throw new SurplusHttpError(
|
||||
`Surplus ${path} returned HTTP ${response.status}${
|
||||
text ? `: ${sanitizedErrorSnippet(text)}` : ""
|
||||
}`,
|
||||
`Surplus ${path} returned HTTP ${response.status}`,
|
||||
response.status,
|
||||
response.headers.get("retry-after"),
|
||||
);
|
||||
@@ -500,9 +498,3 @@ function optionalMicroUsd(value: unknown, field: string): number | null {
|
||||
return microUsd(value, field);
|
||||
}
|
||||
|
||||
function sanitizedErrorSnippet(value: string): string {
|
||||
return value
|
||||
.slice(0, 300)
|
||||
.replace(/(?:si_seller_|sk-or-v1-)[A-Za-z0-9_-]+/g, "[redacted]")
|
||||
.replace(/[\r\n]+/g, " ");
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface SurplusManagerConfig {
|
||||
pollIntervalMs: number;
|
||||
requestTimeoutMs: number;
|
||||
stopAfterTrades: number;
|
||||
settledStatuses: string[];
|
||||
statePath: string;
|
||||
port: number;
|
||||
}
|
||||
@@ -124,6 +125,10 @@ export function loadSurplusManagerConfig(
|
||||
1,
|
||||
1,
|
||||
),
|
||||
settledStatuses: statusList(
|
||||
env.SURPLUS_SETTLED_STATUSES ?? "",
|
||||
"SURPLUS_SETTLED_STATUSES",
|
||||
),
|
||||
statePath: requiredWithDefault(
|
||||
env.SURPLUS_STATE_PATH,
|
||||
"/var/lib/surplus-manager/state.json",
|
||||
@@ -188,6 +193,20 @@ function boundedInteger(
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function statusList(value: string, name: string): string[] {
|
||||
if (value.trim() === "") return [];
|
||||
const statuses = value.split(",").map((status) => status.trim().toLowerCase());
|
||||
const seen = new Set<string>();
|
||||
for (const status of statuses) {
|
||||
if (!/^[a-z][a-z0-9_-]*$/.test(status)) {
|
||||
throw new Error(`${name} contains an invalid status`);
|
||||
}
|
||||
if (seen.has(status)) throw new Error(`${name} contains a duplicate status`);
|
||||
seen.add(status);
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
function httpsUrl(value: string, name: string, hostname: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
|
||||
@@ -70,11 +70,6 @@ interface SurplusControllerOptions {
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
const SETTLED_STATUSES: Record<string, true> = {
|
||||
confirmed: true,
|
||||
paid: true,
|
||||
settled: true,
|
||||
};
|
||||
|
||||
export class SurplusSellerController {
|
||||
private readonly config: SurplusManagerConfig;
|
||||
@@ -403,7 +398,6 @@ export class SurplusSellerController {
|
||||
await this.pauseOffers(confirmedActive);
|
||||
throw new Error("Adopted Surplus create did not reconcile to one active offer");
|
||||
}
|
||||
this.assertAggregateCap(confirmedOffers);
|
||||
const adopted: SurplusControllerState = {
|
||||
version: 1,
|
||||
phase: "active",
|
||||
@@ -412,9 +406,10 @@ export class SurplusSellerController {
|
||||
highestTrades24h: pending.intent.baselineTrades24h,
|
||||
};
|
||||
try {
|
||||
this.assertAggregateCap(confirmedOffers);
|
||||
await this.stateStore.save(adopted);
|
||||
} catch (error) {
|
||||
await this.client.pauseOffer(primary.id, this.mutationId()).catch(() => undefined);
|
||||
await this.pauseOffers(confirmedActive).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
this.state = adopted;
|
||||
@@ -430,10 +425,14 @@ export class SurplusSellerController {
|
||||
if (this.state?.phase !== "active") return;
|
||||
const currentState = this.state;
|
||||
const offers = await this.client.listAllOffers();
|
||||
const activeOffers = offers.filter((offer) => offer.status === "active");
|
||||
const managed = offers.find((offer) => offer.id === currentState.offerId);
|
||||
if (!managed) throw new Error(`Managed Surplus offer is missing: ${currentState.offerId}`);
|
||||
const otherActive = offers.filter(
|
||||
(offer) => offer.status === "active" && offer.id !== currentState.offerId,
|
||||
if (!managed) {
|
||||
await this.pauseOffers(activeOffers);
|
||||
throw new Error(`Managed Surplus offer is missing: ${currentState.offerId}`);
|
||||
}
|
||||
const otherActive = activeOffers.filter(
|
||||
(offer) => offer.id !== currentState.offerId,
|
||||
);
|
||||
if (otherActive.length > 0) {
|
||||
await this.pauseOffers(otherActive);
|
||||
@@ -600,11 +599,22 @@ export class SurplusSellerController {
|
||||
): SurplusEarnings["recentSales"][number] | null {
|
||||
const tradeObservedAt = Date.parse(state.tradeObservedAt);
|
||||
return earnings.recentSales.find((sale) => {
|
||||
if (!SETTLED_STATUSES[sale.settlementStatus.toLowerCase()]) return false;
|
||||
if (sale.model !== state.intent.model || sale.sellerCostMicroUsd <= 0) return false;
|
||||
if (sale.offerId !== null) return sale.offerId === state.offerId;
|
||||
return sale.createdAt !== null &&
|
||||
Date.parse(sale.createdAt) >= tradeObservedAt;
|
||||
if (
|
||||
!this.config.settledStatuses.includes(
|
||||
sale.settlementStatus.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
sale.offerId !== state.offerId ||
|
||||
sale.createdAt === null ||
|
||||
sale.model !== state.intent.model ||
|
||||
sale.sellerCostMicroUsd <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return Date.parse(sale.createdAt) >= tradeObservedAt;
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
@@ -637,7 +647,11 @@ export class SurplusSellerController {
|
||||
if (offer.capDailyUsd === null) {
|
||||
throw new Error(`Active Surplus offer has no daily cap: ${offer.id}`);
|
||||
}
|
||||
activeCap += parseUsdDecimal(offer.capDailyUsd.toFixed(6), "ceil");
|
||||
const capMicroUsd = offer.capDailyUsd * 1_000_000;
|
||||
if (!Number.isSafeInteger(capMicroUsd)) {
|
||||
throw new Error(`Active Surplus offer cap has excess precision: ${offer.id}`);
|
||||
}
|
||||
activeCap += BigInt(capMicroUsd) * 1_000n;
|
||||
}
|
||||
const aggregateCap = parseUsdDecimal(this.config.aggregateCapUsd, "floor");
|
||||
if (activeCap > aggregateCap) {
|
||||
|
||||
@@ -44,9 +44,7 @@ export class OpenRouterKeyClient {
|
||||
const text = await readBoundedText(response, controller);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`OpenRouter key endpoint returned HTTP ${response.status}${
|
||||
text ? `: ${text.slice(0, 200).replace(/[\r\n]+/g, " ")}` : ""
|
||||
}`,
|
||||
`OpenRouter key endpoint returned HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
|
||||
@@ -39,6 +39,7 @@ function config(): SurplusManagerConfig {
|
||||
pollIntervalMs: 300_000,
|
||||
requestTimeoutMs: 120_000,
|
||||
stopAfterTrades: 1,
|
||||
settledStatuses: ["confirmed"],
|
||||
statePath: "/tmp/surplus-test-state.json",
|
||||
port: 8080,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user