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_REPRICE_INTERVAL_MS=300000
|
||||||
SURPLUS_REQUEST_TIMEOUT_MS=120000
|
SURPLUS_REQUEST_TIMEOUT_MS=120000
|
||||||
SURPLUS_STOP_AFTER_SETTLED_REQUESTS=1
|
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
|
SURPLUS_STATE_PATH=/var/lib/surplus-manager/state.json
|
||||||
|
|||||||
@@ -311,9 +311,7 @@ export class SurplusClient {
|
|||||||
const text = await readBoundedText(response, controller);
|
const text = await readBoundedText(response, controller);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new SurplusHttpError(
|
throw new SurplusHttpError(
|
||||||
`Surplus ${path} returned HTTP ${response.status}${
|
`Surplus ${path} returned HTTP ${response.status}`,
|
||||||
text ? `: ${sanitizedErrorSnippet(text)}` : ""
|
|
||||||
}`,
|
|
||||||
response.status,
|
response.status,
|
||||||
response.headers.get("retry-after"),
|
response.headers.get("retry-after"),
|
||||||
);
|
);
|
||||||
@@ -500,9 +498,3 @@ function optionalMicroUsd(value: unknown, field: string): number | null {
|
|||||||
return microUsd(value, field);
|
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;
|
pollIntervalMs: number;
|
||||||
requestTimeoutMs: number;
|
requestTimeoutMs: number;
|
||||||
stopAfterTrades: number;
|
stopAfterTrades: number;
|
||||||
|
settledStatuses: string[];
|
||||||
statePath: string;
|
statePath: string;
|
||||||
port: number;
|
port: number;
|
||||||
}
|
}
|
||||||
@@ -124,6 +125,10 @@ export function loadSurplusManagerConfig(
|
|||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
|
settledStatuses: statusList(
|
||||||
|
env.SURPLUS_SETTLED_STATUSES ?? "",
|
||||||
|
"SURPLUS_SETTLED_STATUSES",
|
||||||
|
),
|
||||||
statePath: requiredWithDefault(
|
statePath: requiredWithDefault(
|
||||||
env.SURPLUS_STATE_PATH,
|
env.SURPLUS_STATE_PATH,
|
||||||
"/var/lib/surplus-manager/state.json",
|
"/var/lib/surplus-manager/state.json",
|
||||||
@@ -188,6 +193,20 @@ function boundedInteger(
|
|||||||
return parsed;
|
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 {
|
function httpsUrl(value: string, name: string, hostname: string): string {
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -70,11 +70,6 @@ interface SurplusControllerOptions {
|
|||||||
log?: (message: string) => void;
|
log?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTLED_STATUSES: Record<string, true> = {
|
|
||||||
confirmed: true,
|
|
||||||
paid: true,
|
|
||||||
settled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export class SurplusSellerController {
|
export class SurplusSellerController {
|
||||||
private readonly config: SurplusManagerConfig;
|
private readonly config: SurplusManagerConfig;
|
||||||
@@ -403,7 +398,6 @@ export class SurplusSellerController {
|
|||||||
await this.pauseOffers(confirmedActive);
|
await this.pauseOffers(confirmedActive);
|
||||||
throw new Error("Adopted Surplus create did not reconcile to one active offer");
|
throw new Error("Adopted Surplus create did not reconcile to one active offer");
|
||||||
}
|
}
|
||||||
this.assertAggregateCap(confirmedOffers);
|
|
||||||
const adopted: SurplusControllerState = {
|
const adopted: SurplusControllerState = {
|
||||||
version: 1,
|
version: 1,
|
||||||
phase: "active",
|
phase: "active",
|
||||||
@@ -412,9 +406,10 @@ export class SurplusSellerController {
|
|||||||
highestTrades24h: pending.intent.baselineTrades24h,
|
highestTrades24h: pending.intent.baselineTrades24h,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
this.assertAggregateCap(confirmedOffers);
|
||||||
await this.stateStore.save(adopted);
|
await this.stateStore.save(adopted);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.client.pauseOffer(primary.id, this.mutationId()).catch(() => undefined);
|
await this.pauseOffers(confirmedActive).catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
this.state = adopted;
|
this.state = adopted;
|
||||||
@@ -430,10 +425,14 @@ export class SurplusSellerController {
|
|||||||
if (this.state?.phase !== "active") return;
|
if (this.state?.phase !== "active") return;
|
||||||
const currentState = this.state;
|
const currentState = this.state;
|
||||||
const offers = await this.client.listAllOffers();
|
const offers = await this.client.listAllOffers();
|
||||||
|
const activeOffers = offers.filter((offer) => offer.status === "active");
|
||||||
const managed = offers.find((offer) => offer.id === currentState.offerId);
|
const managed = offers.find((offer) => offer.id === currentState.offerId);
|
||||||
if (!managed) throw new Error(`Managed Surplus offer is missing: ${currentState.offerId}`);
|
if (!managed) {
|
||||||
const otherActive = offers.filter(
|
await this.pauseOffers(activeOffers);
|
||||||
(offer) => offer.status === "active" && offer.id !== currentState.offerId,
|
throw new Error(`Managed Surplus offer is missing: ${currentState.offerId}`);
|
||||||
|
}
|
||||||
|
const otherActive = activeOffers.filter(
|
||||||
|
(offer) => offer.id !== currentState.offerId,
|
||||||
);
|
);
|
||||||
if (otherActive.length > 0) {
|
if (otherActive.length > 0) {
|
||||||
await this.pauseOffers(otherActive);
|
await this.pauseOffers(otherActive);
|
||||||
@@ -600,11 +599,22 @@ export class SurplusSellerController {
|
|||||||
): SurplusEarnings["recentSales"][number] | null {
|
): SurplusEarnings["recentSales"][number] | null {
|
||||||
const tradeObservedAt = Date.parse(state.tradeObservedAt);
|
const tradeObservedAt = Date.parse(state.tradeObservedAt);
|
||||||
return earnings.recentSales.find((sale) => {
|
return earnings.recentSales.find((sale) => {
|
||||||
if (!SETTLED_STATUSES[sale.settlementStatus.toLowerCase()]) return false;
|
if (
|
||||||
if (sale.model !== state.intent.model || sale.sellerCostMicroUsd <= 0) return false;
|
!this.config.settledStatuses.includes(
|
||||||
if (sale.offerId !== null) return sale.offerId === state.offerId;
|
sale.settlementStatus.toLowerCase(),
|
||||||
return sale.createdAt !== null &&
|
)
|
||||||
Date.parse(sale.createdAt) >= tradeObservedAt;
|
) {
|
||||||
|
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;
|
}) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,7 +647,11 @@ export class SurplusSellerController {
|
|||||||
if (offer.capDailyUsd === null) {
|
if (offer.capDailyUsd === null) {
|
||||||
throw new Error(`Active Surplus offer has no daily cap: ${offer.id}`);
|
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");
|
const aggregateCap = parseUsdDecimal(this.config.aggregateCapUsd, "floor");
|
||||||
if (activeCap > aggregateCap) {
|
if (activeCap > aggregateCap) {
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ export class OpenRouterKeyClient {
|
|||||||
const text = await readBoundedText(response, controller);
|
const text = await readBoundedText(response, controller);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`OpenRouter key endpoint returned HTTP ${response.status}${
|
`OpenRouter key endpoint returned HTTP ${response.status}`,
|
||||||
text ? `: ${text.slice(0, 200).replace(/[\r\n]+/g, " ")}` : ""
|
|
||||||
}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function config(): SurplusManagerConfig {
|
|||||||
pollIntervalMs: 300_000,
|
pollIntervalMs: 300_000,
|
||||||
requestTimeoutMs: 120_000,
|
requestTimeoutMs: 120_000,
|
||||||
stopAfterTrades: 1,
|
stopAfterTrades: 1,
|
||||||
|
settledStatuses: ["confirmed"],
|
||||||
statePath: "/tmp/surplus-test-state.json",
|
statePath: "/tmp/surplus-test-state.json",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user