Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@across-protocol/sdk",
"author": "UMA Team",
"version": "4.3.144",
"version": "4.3.145-alpha.2",
"license": "AGPL-3.0",
"homepage": "https://docs.across.to/reference/sdk",
"repository": {
Expand Down
11 changes: 9 additions & 2 deletions src/coingecko/Coingecko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ export class Coingecko {
return this.call("asset_platforms");
}

call<T>(path: string): Promise<T> {
async call<T>(path: string): Promise<T> {
const sendRequest = async () => {
const { proHost } = this;

Expand All @@ -401,7 +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, this.apiKey === undefined ? this.numRetries : 0, this.retryDelay);
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 } {
Expand Down
107 changes: 93 additions & 14 deletions src/utils/common.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -221,20 +222,98 @@
}

/**
* 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
* @returns The result of the function call.
*/
export function retry<T>(call: () => Promise<T>, times: number, delayS: number): Promise<T> {
let promiseChain = call();
for (let i = 0; i < times; i++)
promiseChain = promiseChain.catch(async () => {
await delay(delayS);
return await call();
});
return promiseChain;
* 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<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

/**
* 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<T = unknown> = {
schema?: Struct<T>;
};

/**
* 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}.
*/
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<RetryOptions> = {
retries: 2,
delaySeconds: 1,
isRetryable: () => true,
};

/**
* 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<T>(
call: () => Promise<unknown>,
options: AttemptOptions<T> & { schema: Struct<T> }
): Promise<Result<T>>;
export function attempt<T>(call: () => Promise<T>, options?: AttemptOptions): Promise<Result<T>>;
export async function attempt<T>(

Check warning on line 271 in src/utils/common.ts

View workflow job for this annotation

GitHub Actions / Lint

Replace `⏎··call:·()·=>·Promise<unknown>,⏎··options:·AttemptOptions<T>·=·{}⏎` with `call:·()·=>·Promise<unknown>,·options:·AttemptOptions<T>·=·{}`

Check warning on line 271 in src/utils/common.ts

View workflow job for this annotation

GitHub Actions / Lint

Replace `⏎··call:·()·=>·Promise<unknown>,⏎··options:·AttemptOptions<T>·=·{}⏎` with `call:·()·=>·Promise<unknown>,·options:·AttemptOptions<T>·=·{}`
call: () => Promise<unknown>,
options: AttemptOptions<T> = {}
): Promise<Result<T>> {
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<T>` wrapping the terminal outcome.
*/
export function retry<T>(
call: () => Promise<unknown>,
options: RetryOptions & AttemptOptions<T> & { schema: Struct<T> }
): Promise<Result<T>>;
export function retry<T>(call: () => Promise<T>, options?: RetryOptions): Promise<Result<T>>;
export async function retry<T>(
call: () => Promise<unknown>,
options: RetryOptions & AttemptOptions<T> = {}
): Promise<Result<T>> {
const resolved: Required<RetryOptions> = { ...DEFAULT_RETRY_OPTIONS, ...options };
const backoffSeconds = (n: number): number => resolved.delaySeconds * 2 ** n + Math.random();

for (let nAttempts = 0; ; ++nAttempts) {
try {
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)) };
}
await delay(backoffSeconds(nAttempts));
}
}
}

export type TransactionCostEstimate = {
Expand Down
195 changes: 182 additions & 13 deletions test/common.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,197 @@
import assert from "assert";

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 } from "./utils";
import { expect, sinon } from "./utils";

describe("Utils test", () => {
it("retry", async () => {
const failN = (numFails: number) => {
return () =>
new Promise((resolve, reject) => {
new Promise<boolean>((resolve, reject) => {
if (numFails-- > 0) {
reject();
reject(new Error("fail"));
}
resolve(true);
});
};
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)),
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)", () => {
// 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);
const result = await retry(fn, { retries: 2, delaySeconds: 0 });
expect(result.ok).to.be.true;
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);
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 {
clock.restore();
}
});

it("exhausts retries and surfaces failure when failures outlast the budget", async () => {
const fn = makeFailingFn(3);
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");
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);
});

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";
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[] = [];
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();
}
});

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", () => {
Expand Down
Loading