From 538175da22beb82a519c56d44fd143d94687bb5b Mon Sep 17 00:00:00 2001 From: Paul <108695806+pxrl@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:53:18 +0000 Subject: [PATCH 1/4] refactor(retry): Caller-supplied retry gating There's a downstream need to handle retries intelligently - both for regular HTTPS queries, but also for arbitrary promises that hide the underlying requests. Upgrade retry() to allow the caller to supply a handler that determines whether a failed request should be retried or not. This can be used intelligently to evaluate (for example) HTTP(S) failures - i.e. to discriminate on the resulting status. --- src/coingecko/Coingecko.ts | 5 +- src/utils/common.ts | 49 ++++++++++++++----- test/common.test.ts | 98 +++++++++++++++++++++++++++++++++++--- 3 files changed, 133 insertions(+), 19 deletions(-) diff --git a/src/coingecko/Coingecko.ts b/src/coingecko/Coingecko.ts index 80a669aa5..30fb81161 100644 --- a/src/coingecko/Coingecko.ts +++ b/src/coingecko/Coingecko.ts @@ -401,7 +401,10 @@ export class Coingecko { }; // Note: If a pro API key is configured, there is no need to retry as the Pro API will act as the basic's fall back. - return retry(sendRequest, this.apiKey === undefined ? this.numRetries : 0, this.retryDelay); + return retry(sendRequest, { + retries: this.apiKey === undefined ? this.numRetries : 0, + delaySeconds: this.retryDelay, + }); } protected getPriceCache(currency: string, platform_id: string): { [addr: string]: CoinGeckoPrice } { diff --git a/src/utils/common.ts b/src/utils/common.ts index 4349a790c..9f56d069f 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -221,20 +221,47 @@ export function delay(seconds: number) { } /** - * Attempt to retry a function call a number of times with a delay between each attempt - * @param call The function to call - * @param times The number of times to retry - * @param delayS The number of seconds to delay between each attempt + * Configures {@link retry}. Retries always use exponential backoff + * (`delaySeconds * 2 ** attempt + random()` seconds) to play nicely with upstream + * rate-limits; callers that want tighter spacing should lower {@link delaySeconds}. + */ +export type RetryOptions = { + /** Maximum number of retry attempts after the initial call (total attempts = retries + 1). Defaults to 2 (3 total tries). */ + retries?: number; + /** Base delay in seconds for the exponential backoff. Defaults to 1. */ + delaySeconds?: number; + /** Predicate evaluated against the thrown error to decide whether to retry. Defaults to retrying every error. */ + isRetryable?: (err: unknown) => boolean; +}; + +const DEFAULT_RETRY_OPTIONS: Required = { + retries: 2, + delaySeconds: 1, + isRetryable: () => true, +}; + +/** + * Attempt to retry a function call with exponential backoff and a retryability predicate. + * @param call The function to call. + * @param options Retry configuration — see {@link RetryOptions}. All fields are optional; omitted fields inherit the SDK defaults. * @returns The result of the function call. */ -export function retry(call: () => Promise, times: number, delayS: number): Promise { - let promiseChain = call(); - for (let i = 0; i < times; i++) - promiseChain = promiseChain.catch(async () => { - await delay(delayS); +export function retry(call: () => Promise, options: RetryOptions = {}): Promise { + const resolved: Required = { ...DEFAULT_RETRY_OPTIONS, ...options }; + const backoffSeconds = (attempt: number): number => resolved.delaySeconds * 2 ** attempt + Math.random(); + + const attempt = async (nAttempts: number): Promise => { + try { return await call(); - }); - return promiseChain; + } catch (err) { + if (nAttempts >= resolved.retries || !resolved.isRetryable(err)) { + throw err; + } + await delay(backoffSeconds(nAttempts)); + return attempt(nAttempts + 1); + } + }; + return attempt(0); } export type TransactionCostEstimate = { diff --git a/test/common.test.ts b/test/common.test.ts index 920d5323d..c4be07349 100644 --- a/test/common.test.ts +++ b/test/common.test.ts @@ -2,7 +2,7 @@ import assert from "assert"; import { retry, toBNWei } from "../src/utils/common"; import { BigNumber, parseUnits } from "../src/utils/BigNumberUtils"; -import { expect } from "./utils"; +import { expect, sinon } from "./utils"; describe("Utils test", () => { it("retry", async () => { @@ -16,15 +16,99 @@ describe("Utils test", () => { }); }; await Promise.all([ - assert.doesNotReject(() => retry(failN(0), 0, 1)), - assert.rejects(() => retry(failN(1), 0, 1)), - assert.doesNotReject(() => retry(failN(1), 1, 1)), - assert.rejects(() => retry(failN(2), 1, 1)), - assert.doesNotReject(() => retry(failN(2), 2, 1)), - assert.rejects(() => retry(failN(3), 2, 1)), + assert.doesNotReject(() => retry(failN(0), { retries: 0, delaySeconds: 0 })), + assert.rejects(() => retry(failN(1), { retries: 0, delaySeconds: 0 })), + assert.doesNotReject(() => retry(failN(1), { retries: 1, delaySeconds: 0 })), + assert.rejects(() => retry(failN(2), { retries: 1, delaySeconds: 0 })), + assert.doesNotReject(() => retry(failN(2), { retries: 2, delaySeconds: 0 })), + assert.rejects(() => retry(failN(3), { retries: 2, delaySeconds: 0 })), ]); }); + describe("retry (options form)", () => { + // Fails the first `numFails` invocations with the supplied error, then resolves. + const makeFailingFn = (numFails: number, err: unknown = new Error("boom")) => { + const spy = sinon.spy(() => { + if (spy.callCount <= numFails) { + return Promise.reject(err); + } + return Promise.resolve(true); + }); + return spy; + }; + + it("retries up to `retries` times on retryable errors", async () => { + const fn = makeFailingFn(2); + await retry(fn, { retries: 2, delaySeconds: 0 }); + expect(fn.callCount).to.equal(3); + }); + + it("uses default retries=2 when options are omitted", async () => { + // Stub setTimeout so the test doesn't actually wait. + const clock = sinon.stub(global, "setTimeout").callsFake(((fn: () => void) => { + fn(); + return 0 as unknown as NodeJS.Timeout; + }) as typeof setTimeout); + try { + const fn = makeFailingFn(5); + await assert.rejects(() => retry(fn)); + // 2 retries after initial = 3 total attempts. + expect(fn.callCount).to.equal(3); + } finally { + clock.restore(); + } + }); + + it("exhausts retries and rejects when failures outlast the budget", async () => { + const fn = makeFailingFn(3); + await assert.rejects(() => retry(fn, { retries: 2, delaySeconds: 0 })); + expect(fn.callCount).to.equal(3); + }); + + it("stops immediately when isRetryable returns false", async () => { + const fn = makeFailingFn(5, new Error("non-retryable")); + const isRetryable = sinon.spy((err: unknown) => (err as Error).message !== "non-retryable"); + await assert.rejects(() => retry(fn, { retries: 5, delaySeconds: 0, isRetryable })); + expect(fn.callCount).to.equal(1); + expect(isRetryable.callCount).to.equal(1); + }); + + it("retries only errors matching isRetryable", async () => { + const fn = sinon.spy(() => { + if (fn.callCount === 1) return Promise.reject(new Error("transient")); + if (fn.callCount === 2) return Promise.reject(new Error("fatal")); + return Promise.resolve(true); + }); + const isRetryable = (err: unknown) => (err as Error).message === "transient"; + await assert.rejects(() => retry(fn, { retries: 3, delaySeconds: 0, isRetryable }), /fatal/); + // First call threw "transient" → retried; second call threw "fatal" → stopped. + expect(fn.callCount).to.equal(2); + }); + + it("uses exponential backoff by default", async () => { + // Stub setTimeout to capture the waits without actually delaying. + const timeouts: number[] = []; + const clock = sinon.stub(global, "setTimeout").callsFake(((fn: () => void, ms: number) => { + timeouts.push(ms); + fn(); + return 0 as unknown as NodeJS.Timeout; + }) as typeof setTimeout); + + try { + const fn = makeFailingFn(2); + await retry(fn, { retries: 2, delaySeconds: 1 }); + // Expect two waits, each ~= delaySeconds * 2^attempt + jitter, in milliseconds. + // attempt=0 → (1 + [0,1)) s → [1000, 2000) ms + // attempt=1 → (2 + [0,1)) s → [2000, 3000) ms + expect(timeouts).to.have.length(2); + expect(timeouts[0]).to.be.at.least(1000).and.below(2000); + expect(timeouts[1]).to.be.at.least(2000).and.below(3000); + } finally { + clock.restore(); + } + }); + }); + describe("toBNWei", () => { describe("basic inputs", () => { it("should convert a string integer to BigNumber with default 18 decimals", () => { From 2f60a3ab22754f21b055d2c37c0f32d159d0e4a6 Mon Sep 17 00:00:00 2001 From: Paul <108695806+pxrl@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:37:28 +0000 Subject: [PATCH 2/4] alpha --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fcb874ac5..54f6f6001 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@across-protocol/sdk", "author": "UMA Team", - "version": "4.3.144", + "version": "4.3.145-alpha.0", "license": "AGPL-3.0", "homepage": "https://docs.across.to/reference/sdk", "repository": { From 5a77ee1f767f40938a15fbb7db36003b53509681 Mon Sep 17 00:00:00 2001 From: Paul <108695806+pxrl@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:40:44 +0000 Subject: [PATCH 3/4] iterate --- package.json | 2 +- src/coingecko/Coingecko.ts | 8 ++++-- src/utils/common.ts | 28 +++++++++++++------- test/common.test.ts | 54 ++++++++++++++++++++++++++------------ 4 files changed, 62 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 54f6f6001..b201e2302 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@across-protocol/sdk", "author": "UMA Team", - "version": "4.3.145-alpha.0", + "version": "4.3.145-alpha.1", "license": "AGPL-3.0", "homepage": "https://docs.across.to/reference/sdk", "repository": { diff --git a/src/coingecko/Coingecko.ts b/src/coingecko/Coingecko.ts index 30fb81161..9fe94c172 100644 --- a/src/coingecko/Coingecko.ts +++ b/src/coingecko/Coingecko.ts @@ -378,7 +378,7 @@ export class Coingecko { return this.call("asset_platforms"); } - call(path: string): Promise { + async call(path: string): Promise { const sendRequest = async () => { const { proHost } = this; @@ -401,10 +401,14 @@ export class Coingecko { }; // Note: If a pro API key is configured, there is no need to retry as the Pro API will act as the basic's fall back. - return retry(sendRequest, { + const result = await retry(sendRequest, { retries: this.apiKey === undefined ? this.numRetries : 0, delaySeconds: this.retryDelay, }); + if (!result.ok) { + throw result.error; + } + return result.value; } protected getPriceCache(currency: string, platform_id: string): { [addr: string]: CoinGeckoPrice } { diff --git a/src/utils/common.ts b/src/utils/common.ts index 9f56d069f..18ed7f645 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -220,6 +220,13 @@ export function delay(seconds: number) { return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); } +/** + * Discriminated union for operations that can fail. Callers narrow on `.ok`: success + * carries `value`, failure carries the original `error` for inspection. Avoids the + * control-flow cost of try/catch at call sites that want to handle failure as data. + */ +export type Result = { ok: true; value: T } | { ok: false; error: E }; + /** * Configures {@link retry}. Retries always use exponential backoff * (`delaySeconds * 2 ** attempt + random()` seconds) to play nicely with upstream @@ -241,27 +248,28 @@ const DEFAULT_RETRY_OPTIONS: Required = { }; /** - * Attempt to retry a function call with exponential backoff and a retryability predicate. + * Attempt a function call with exponential backoff and a retryability predicate, returning a + * {@link Result} that encodes success or the terminal failure. Exhausted retries and errors + * rejected by `isRetryable` both surface as `{ ok: false, error }` — the caller decides how + * to react instead of catching a throw. * @param call The function to call. - * @param options Retry configuration — see {@link RetryOptions}. All fields are optional; omitted fields inherit the SDK defaults. - * @returns The result of the function call. + * @param options Retry configuration — see {@link RetryOptions}. All fields are optional. + * @returns A `Result` wrapping the terminal outcome. */ -export function retry(call: () => Promise, options: RetryOptions = {}): Promise { +export async function retry(call: () => Promise, options: RetryOptions = {}): Promise> { const resolved: Required = { ...DEFAULT_RETRY_OPTIONS, ...options }; const backoffSeconds = (attempt: number): number => resolved.delaySeconds * 2 ** attempt + Math.random(); - const attempt = async (nAttempts: number): Promise => { + for (let nAttempts = 0; ; ++nAttempts) { try { - return await call(); + return { ok: true, value: await call() }; } catch (err) { if (nAttempts >= resolved.retries || !resolved.isRetryable(err)) { - throw err; + return { ok: false, error: err instanceof Error ? err : new Error(String(err)) }; } await delay(backoffSeconds(nAttempts)); - return attempt(nAttempts + 1); } - }; - return attempt(0); + } } export type TransactionCostEstimate = { diff --git a/test/common.test.ts b/test/common.test.ts index c4be07349..bdf647e50 100644 --- a/test/common.test.ts +++ b/test/common.test.ts @@ -1,5 +1,3 @@ -import assert from "assert"; - import { retry, toBNWei } from "../src/utils/common"; import { BigNumber, parseUnits } from "../src/utils/BigNumberUtils"; import { expect, sinon } from "./utils"; @@ -8,21 +6,22 @@ describe("Utils test", () => { it("retry", async () => { const failN = (numFails: number) => { return () => - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { if (numFails-- > 0) { - reject(); + reject(new Error("fail")); } resolve(true); }); }; - await Promise.all([ - assert.doesNotReject(() => retry(failN(0), { retries: 0, delaySeconds: 0 })), - assert.rejects(() => retry(failN(1), { retries: 0, delaySeconds: 0 })), - assert.doesNotReject(() => retry(failN(1), { retries: 1, delaySeconds: 0 })), - assert.rejects(() => retry(failN(2), { retries: 1, delaySeconds: 0 })), - assert.doesNotReject(() => retry(failN(2), { retries: 2, delaySeconds: 0 })), - assert.rejects(() => retry(failN(3), { retries: 2, delaySeconds: 0 })), + const results = await Promise.all([ + retry(failN(0), { retries: 0, delaySeconds: 0 }), + retry(failN(1), { retries: 0, delaySeconds: 0 }), + retry(failN(1), { retries: 1, delaySeconds: 0 }), + retry(failN(2), { retries: 1, delaySeconds: 0 }), + retry(failN(2), { retries: 2, delaySeconds: 0 }), + retry(failN(3), { retries: 2, delaySeconds: 0 }), ]); + expect(results.map((r) => r.ok)).to.deep.equal([true, false, true, false, true, false]); }); describe("retry (options form)", () => { @@ -39,7 +38,8 @@ describe("Utils test", () => { it("retries up to `retries` times on retryable errors", async () => { const fn = makeFailingFn(2); - await retry(fn, { retries: 2, delaySeconds: 0 }); + const result = await retry(fn, { retries: 2, delaySeconds: 0 }); + expect(result.ok).to.be.true; expect(fn.callCount).to.equal(3); }); @@ -51,7 +51,8 @@ describe("Utils test", () => { }) as typeof setTimeout); try { const fn = makeFailingFn(5); - await assert.rejects(() => retry(fn)); + const result = await retry(fn); + expect(result.ok).to.be.false; // 2 retries after initial = 3 total attempts. expect(fn.callCount).to.equal(3); } finally { @@ -59,16 +60,21 @@ describe("Utils test", () => { } }); - it("exhausts retries and rejects when failures outlast the budget", async () => { + it("exhausts retries and surfaces failure when failures outlast the budget", async () => { const fn = makeFailingFn(3); - await assert.rejects(() => retry(fn, { retries: 2, delaySeconds: 0 })); + const result = await retry(fn, { retries: 2, delaySeconds: 0 }); + expect(result.ok).to.be.false; expect(fn.callCount).to.equal(3); }); it("stops immediately when isRetryable returns false", async () => { const fn = makeFailingFn(5, new Error("non-retryable")); const isRetryable = sinon.spy((err: unknown) => (err as Error).message !== "non-retryable"); - await assert.rejects(() => retry(fn, { retries: 5, delaySeconds: 0, isRetryable })); + const result = await retry(fn, { retries: 5, delaySeconds: 0, isRetryable }); + expect(result.ok).to.be.false; + if (!result.ok) { + expect(result.error.message).to.equal("non-retryable"); + } expect(fn.callCount).to.equal(1); expect(isRetryable.callCount).to.equal(1); }); @@ -80,11 +86,25 @@ describe("Utils test", () => { return Promise.resolve(true); }); const isRetryable = (err: unknown) => (err as Error).message === "transient"; - await assert.rejects(() => retry(fn, { retries: 3, delaySeconds: 0, isRetryable }), /fatal/); + const result = await retry(fn, { retries: 3, delaySeconds: 0, isRetryable }); + expect(result.ok).to.be.false; + if (!result.ok) { + expect(result.error.message).to.equal("fatal"); + } // First call threw "transient" → retried; second call threw "fatal" → stopped. expect(fn.callCount).to.equal(2); }); + it("wraps non-Error throws in an Error", async () => { + const fn = sinon.spy(() => Promise.reject("string-throw")); + const result = await retry(fn, { retries: 0, delaySeconds: 0 }); + expect(result.ok).to.be.false; + if (!result.ok) { + expect(result.error).to.be.instanceOf(Error); + expect(result.error.message).to.equal("string-throw"); + } + }); + it("uses exponential backoff by default", async () => { // Stub setTimeout to capture the waits without actually delaying. const timeouts: number[] = []; From dabc9641426425b9f1073561fa59a9641a11b019 Mon Sep 17 00:00:00 2001 From: Paul <108695806+pxrl@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:33:04 +0000 Subject: [PATCH 4/4] iterate --- package.json | 2 +- src/utils/common.ts | 64 ++++++++++++++++++++++++++++++++++++------- test/common.test.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 121 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index b201e2302..e7c488bdf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@across-protocol/sdk", "author": "UMA Team", - "version": "4.3.145-alpha.1", + "version": "4.3.145-alpha.2", "license": "AGPL-3.0", "homepage": "https://docs.across.to/reference/sdk", "repository": { diff --git a/src/utils/common.ts b/src/utils/common.ts index 18ed7f645..0621f27d5 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,6 +1,7 @@ import Decimal from "decimal.js"; import bs58 from "bs58"; import { ethers } from "ethers"; +import { create, Struct } from "superstruct"; import { BigNumber, BigNumberish, BN, formatUnits, parseUnits, toBN } from "./BigNumberUtils"; import { ConvertDecimals } from "./FormattingUtils"; @@ -228,7 +229,16 @@ export function delay(seconds: number) { export type Result = { ok: true; value: T } | { ok: false; error: E }; /** - * Configures {@link retry}. Retries always use exponential backoff + * Configures a single {@link attempt}. `schema`, when supplied, runs the successful value + * through `create(value, schema)` before wrapping — validation failures surface as a + * `{ ok: false }` Result like any other throw. + */ +export type AttemptOptions = { + schema?: Struct; +}; + +/** + * Configures the outer loop of {@link retry}. Backoff is always exponential * (`delaySeconds * 2 ** attempt + random()` seconds) to play nicely with upstream * rate-limits; callers that want tighter spacing should lower {@link delaySeconds}. */ @@ -248,21 +258,55 @@ const DEFAULT_RETRY_OPTIONS: Required = { }; /** - * Attempt a function call with exponential backoff and a retryability predicate, returning a - * {@link Result} that encodes success or the terminal failure. Exhausted retries and errors - * rejected by `isRetryable` both surface as `{ ok: false, error }` — the caller decides how - * to react instead of catching a throw. - * @param call The function to call. - * @param options Retry configuration — see {@link RetryOptions}. All fields are optional. + * Run a failable operation once, catching throws and returning a {@link Result}. When + * `schema` is supplied, the successful value is validated via `create(value, schema)` + * before being wrapped; a failed validation lands in `{ ok: false, error }` with the + * superstruct error preserved. + */ +export function attempt( + call: () => Promise, + options: AttemptOptions & { schema: Struct } +): Promise>; +export function attempt(call: () => Promise, options?: AttemptOptions): Promise>; +export async function attempt( + call: () => Promise, + options: AttemptOptions = {} +): Promise> { + try { + const raw = await call(); + const value = options.schema ? create(raw, options.schema) : (raw as T); + return { ok: true, value }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err : new Error(String(err)) }; + } +} + +/** + * Loop {@link attempt} with exponential backoff until success or a terminal failure — + * exhausted retry budget or a failure rejected by `isRetryable`. The inner attempt's + * validation (via `schema`) participates in the retry budget; a malformed response is + * retried unless `isRetryable` opts out. + * @param call The function to call on each attempt. + * @param options Retry + attempt configuration (see {@link RetryOptions}, {@link AttemptOptions}). * @returns A `Result` wrapping the terminal outcome. */ -export async function retry(call: () => Promise, options: RetryOptions = {}): Promise> { +export function retry( + call: () => Promise, + options: RetryOptions & AttemptOptions & { schema: Struct } +): Promise>; +export function retry(call: () => Promise, options?: RetryOptions): Promise>; +export async function retry( + call: () => Promise, + options: RetryOptions & AttemptOptions = {} +): Promise> { const resolved: Required = { ...DEFAULT_RETRY_OPTIONS, ...options }; - const backoffSeconds = (attempt: number): number => resolved.delaySeconds * 2 ** attempt + Math.random(); + const backoffSeconds = (n: number): number => resolved.delaySeconds * 2 ** n + Math.random(); for (let nAttempts = 0; ; ++nAttempts) { try { - return { ok: true, value: await call() }; + const raw = await call(); + const value = options.schema ? create(raw, options.schema) : (raw as T); + return { ok: true, value }; } catch (err) { if (nAttempts >= resolved.retries || !resolved.isRetryable(err)) { return { ok: false, error: err instanceof Error ? err : new Error(String(err)) }; diff --git a/test/common.test.ts b/test/common.test.ts index bdf647e50..a20048869 100644 --- a/test/common.test.ts +++ b/test/common.test.ts @@ -1,4 +1,5 @@ -import { retry, toBNWei } from "../src/utils/common"; +import { number, object, string } from "superstruct"; +import { attempt, retry, toBNWei } from "../src/utils/common"; import { BigNumber, parseUnits } from "../src/utils/BigNumberUtils"; import { expect, sinon } from "./utils"; @@ -127,6 +128,70 @@ describe("Utils test", () => { clock.restore(); } }); + + it("runs schema validation on each attempt and retries structural failures", async () => { + const Shape = object({ wdQuota: number(), usedWdQuota: number() }); + // First call returns a malformed payload; second returns a valid one. + const fn = sinon.spy(() => { + if (fn.callCount === 1) return Promise.resolve({ wdQuota: "wrong-type", usedWdQuota: 0 }); + return Promise.resolve({ wdQuota: 100, usedWdQuota: 10 }); + }); + const result = await retry(fn, { retries: 2, delaySeconds: 0, schema: Shape }); + expect(result.ok).to.be.true; + if (result.ok) { + expect(result.value).to.deep.equal({ wdQuota: 100, usedWdQuota: 10 }); + } + expect(fn.callCount).to.equal(2); + }); + }); + + describe("attempt", () => { + it("returns ok:true with the raw value when no schema is supplied", async () => { + const result = await attempt(() => Promise.resolve(42)); + expect(result.ok).to.be.true; + if (result.ok) { + expect(result.value).to.equal(42); + } + }); + + it("catches throws into ok:false without retrying", async () => { + const fn = sinon.spy(() => Promise.reject(new Error("boom"))); + const result = await attempt(fn); + expect(result.ok).to.be.false; + if (!result.ok) { + expect(result.error.message).to.equal("boom"); + } + expect(fn.callCount).to.equal(1); + }); + + it("validates the value with schema and narrows the returned type", async () => { + const Shape = object({ name: string() }); + const result = await attempt(() => Promise.resolve({ name: "binance" }), { schema: Shape }); + expect(result.ok).to.be.true; + if (result.ok) { + // result.value is typed as { name: string } — the .length check would be a compile + // error without schema-driven narrowing. + expect(result.value.name).to.equal("binance"); + } + }); + + it("surfaces schema mismatches as ok:false", async () => { + const Shape = object({ name: string() }); + const result = await attempt(() => Promise.resolve({ name: 123 }), { schema: Shape }); + expect(result.ok).to.be.false; + if (!result.ok) { + expect(result.error).to.be.instanceOf(Error); + } + }); + + it("wraps non-Error throws in an Error", async () => { + const result = await attempt(() => Promise.reject("string-throw")); + expect(result.ok).to.be.false; + if (!result.ok) { + expect(result.error).to.be.instanceOf(Error); + expect(result.error.message).to.equal("string-throw"); + } + }); }); describe("toBNWei", () => {