Better error handling, caching and deduplication. Added tests to api and db logic

This commit is contained in:
Vítor Vieira 2025-11-28 15:07:10 +00:00
parent f4de1c7bc2
commit 628d7ade76
9 changed files with 510 additions and 226 deletions

5
.env
View file

@ -1,3 +1,6 @@
PAIRS=BTC-USD,ETH-USD,XRP-USD
PAIRS=BTC-USD,ETH-USD,XRP-USD,BTC-EUR,ETH-EUR,CNYUSD
INTERVAL=5000
THRESHOLD=0.05
POSTGRES_USER=uphold
POSTGRES_PASSWORD=uphold
POSTGRES_DB=uphold_events

View file

@ -1,12 +1,12 @@
FROM docker.io/node:20-alpine
WORKDIR /app
COPY package*.json ./
# RUN npm ci --omit=dev
RUN npm ci
COPY src/ ./src/
COPY index.js ./
RUN npm ci --omit=dev
COPY . .
RUN addgroup -g 1001 nodejs && adduser -S -G nodejs -u 1001 nodejs
USER nodejs
ENTRYPOINT ["node", "index.js"]
CMD ["--pairs", "BTC-USD"]

View file

@ -5,17 +5,19 @@ services:
db:
condition: service_healthy
environment:
- DATABASE_URL=postgres://uphold:uphold_password@db:5432/uphold_alerts
- PAIRS=${PAIRS:-BTC-USD,ETH-USD}
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- PAIRS=${PAIRS:-BTC-USD}
- INTERVAL=${INTERVAL:-5000}
- THRESHOLD=${THRESHOLD:-0.01}
command: ["--pairs", "${PAIRS:-BTC-USD,ETH-USD}", "--interval", "${INTERVAL:-5000}", "--threshold", "${THRESHOLD:-0.01}"]
healthcheck:
test: ["CMD", "node", "-e", "require('node:process').exit(require('fs').existsSync('/tmp/healthy') ? 0 : 1)"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
db:
image: docker.io/postgres:15-alpine
environment:
- POSTGRES_USER=uphold
- POSTGRES_PASSWORD=uphold_password
- POSTGRES_DB=uphold_alerts
env_file: ".env"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:

View file

@ -1,22 +1,39 @@
import { parseArgs } from "node:util";
import { prefetchRates } from "./src/api.js";
import { Bot } from "./src/bot.js";
import { initDB } from "./src/db.js";
import logger from "./src/logger.js";
import { initDB, insertIntoDB, closePool } from "./src/db.js";
process.on("uncaughtException", (err) => {
logger.fatal(err, "Uncaught exception. Application crashing...");
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
logger.fatal(reason, "Unhandled promise Rejection");
process.exit(1);
});
const options = {
pairs: {
type: "string",
short: "p",
default: "BTC-USD",
default: process.env.PAIRS || "BTC-USD",
},
interval: {
type: "string",
short: "i",
default: "5000",
default: process.env.INTERVAL || "5000",
},
threshold: {
type: "string",
short: "t",
default: "0.01",
default: process.env.THRESHOLD || "0.01",
},
stats: {
type: "boolean",
default: process.env.STATS === "true",
description: "Show performance stats periodically",
},
};
@ -31,36 +48,58 @@ const pairs = values.pairs.split(",").map((p) => p.trim());
const interval = parseInt(values.interval, 10);
const threshold = parseFloat(values.threshold);
// Basic validation
if (isNaN(interval) || interval < 1000) {
console.error("Error: Interval must be a number >= 1000ms");
if (isNaN(interval) || interval < 100) {
logger.error("Interval must be a number larger than 100ms");
process.exit(1);
}
if (pairs.length === 0 || pairs.some((p) => !p.match(/^[A-Z]+-?[A-Z]+$/))) {
logger.error("Invalid pair format (expected BTC-USD or CNYUSD)");
process.exit(1);
}
async function run() {
console.log("Starting Uphold interview bot...");
console.log(
`Configuration: Pairs=[${pairs.join(", ")}] Interval=${interval}ms Threshold=${threshold}%`,
async function main() {
logger.info("Uphold price alert bot starting...");
logger.info(
`[WATCHING] ${pairs.join(", ")} | Every ${interval}ms | Threshold ${threshold}%`,
);
if (process.env.DATABASE_URL) {
await initDB();
} else {
console.warn("[Warning] No DATABASE_URL found. Alerts will NOT be saved.");
try {
await prefetchRates(pairs);
} catch (err) {
logger.error(err, "Critical failure during cache warming");
process.exit(1);
}
const bots = pairs.map((pair) => new Bot(pair, interval, threshold));
const handleAlert = async (alertData) => {
await insertIntoDB(alertData);
};
const bots = pairs.map((pair) => {
return new Bot(
{
pair,
interval,
threshold,
},
handleAlert,
);
});
bots.forEach((b) => b.start());
process.on("SIGINT", () => {
console.log("\nShutting down...");
bots.forEach((bot) => bot.stop());
const shutdown = async () => {
logger.info("Shutting down...");
bots.forEach((b) => b.stop());
await closePool();
process.exit(0);
});
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
run().catch((err) => {
console.error("Fatal Error:", err);
main().catch((err) => {
logger.fatal(err, "Fatal error in main loop");
process.exit(1);
});

View file

@ -13,10 +13,14 @@
"description": "Technical project for Backend Engineer role at Uphold",
"dependencies": {
"bignumber.js": "^9.3.1",
"pg": "^8.16.3"
"p-queue": "^9.0.1",
"pg": "^8.16.3",
"pino": "^10.1.0",
"quick-lru": "^7.3.0"
},
"devDependencies": {
"nock": "^14.0.10",
"pino-pretty": "^13.1.2",
"vitest": "^4.0.14"
}
}

View file

@ -1,16 +1,93 @@
import { BigNumber } from "bignumber.js";
import PQueue from "p-queue";
import logger from "./logger.js";
export async function fetchRate(pair) {
const url = `https://api.uphold.com/v0/ticker/${pair}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Response status ${response.status}`);
const cache = new Map();
const inflight = new Map();
const CACHE_TTL = 4500;
const queue = new PQueue({ interval: 1000, intervalCap: 15 });
export async function fetchRate(pair, forceRefresh = false) {
const key = pair.toUpperCase();
if (!forceRefresh) {
const entry = cache.get(key);
if (entry && Date.now() - entry.ts < CACHE_TTL) {
return entry.price;
}
}
const data = await response.json();
if (inflight.has(key)) {
return inflight.get(key);
}
const taskPromise = queue.add(async () => {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const res = await fetch(`https://api.uphold.com/v0/ticker/${key}`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (res.status === 429) {
throw new Error("Rate limited");
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
if (!data.ask || isNaN(parseFloat(data.ask))) {
throw new Error("Invalid data format");
throw new Error("Invalid ask price");
}
return new BigNumber(data.ask);
const price = new BigNumber(data.ask);
cache.set(key, { price, ts: Date.now() });
return price;
} catch (err) {
const isLastAttempt = attempt === 3;
if (!isLastAttempt) {
logger.warn(
{ pair: key, attempt, error: err.message },
"Fetch failed, retrying...",
);
await new Promise((r) =>
setTimeout(r, 1000 * Math.pow(2, attempt - 1)),
);
} else {
throw err;
}
}
}
});
inflight.set(key, taskPromise);
taskPromise
.finally(() => {
inflight.delete(key);
})
.catch(() => {});
return taskPromise;
}
export async function prefetchRates(pairs) {
logger.info({ pairs }, "Populating cache...");
const results = await Promise.allSettled(pairs.map((p) => fetchRate(p)));
const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) {
logger.error(
{ count: failed.length },
"Some currencies failed to prefetch, have to wati for loop",
);
}
}

View file

@ -1,82 +1,87 @@
import { fetchRate } from "./api.js";
import { insertIntoDB } from "./db.js";
import logger from "./logger.js";
export class Bot {
constructor(pair, interval, threshold) {
this.pair = pair;
this.interval = interval;
this.threshold = threshold;
constructor(config, onAlert) {
this.pair = config.pair;
this.interval = config.interval;
this.threshold = config.threshold;
this.onAlert = onAlert || (async () => {});
this.lastPrice = null;
this.running = null;
this.timer = null;
this.running = false;
}
start() {
if (!this.running) {
if (this.running) return;
this.running = true;
console.log(`[STARTED] Monitoring ${this.pair}`);
this.check().catch((err) =>
console.error(
`[ERROR] Initialization failed for ${this.pair}: ${err.message}`,
),
logger.info(
`[${this.pair}] Monitoring started (${this.interval}ms, +/-${this.threshold}%)`,
);
this.timer = setInterval(async () => {
try {
await this.check();
} catch (err) {
console.error(`[ERROR] ${this.pair}: ${err.message}`);
}
}, this.interval);
}
this.loop();
}
stop() {
this.isRunning = false;
if (this.timer) {
clearInterval(this.timer);
}
if (!this.running) return;
this.running = false;
if (this.timer) clearTimeout(this.timer);
logger.info(`[${this.pair}] Stopped`);
}
async check() {
const currentPrice = await fetchRate(this.pair);
loop = async () => {
try {
const price = await fetchRate(this.pair);
if (!this.lastPrice) {
this.lastPrice = currentPrice;
console.log(`[INIT] ${this.pair} set to ${currentPrice.toFixed(2)}`);
return;
}
this.lastPrice = price;
logger.info(`[${this.pair}] Initial price: ${price.toFixed(2)}`);
} else {
const change = price
.minus(this.lastPrice)
.dividedBy(this.lastPrice)
.multipliedBy(100);
const diff = currentPrice.minus(this.lastPrice);
const percentDiff = diff.dividedBy(this.lastPrice).multipliedBy(100);
if (percentDiff.abs().gte(this.threshold)) {
const direction = diff.isPositive() ? "UP" : "DOWN";
const priceStr = currentPrice.toFixed(2);
const prevPriceStr = this.lastPrice.toFixed(2);
const pctNum = percentDiff.toNumber();
this.alert(direction, priceStr, pctNum);
insertIntoDB({
if (change.abs().gte(this.threshold)) {
const dir = change.gt(0) ? "UP" : "DOWN";
logger.info(
{
pair: this.pair,
direction,
previousPrice: prevPriceStr,
newPrice: priceStr,
percentChange: pctNum,
direction: dir,
change: `${change.toFixed(4)}%`,
prev: this.lastPrice.toFixed(6),
curr: price.toFixed(6),
},
`[ALERT] ${this.pair} ${dir} ${change.toFixed(4)}%`,
);
const alertData = {
pair: this.pair,
direction: dir,
previousPrice: this.lastPrice.toFixed(6),
newPrice: price.toFixed(6),
percentChange: change.toFixed(4),
interval: this.interval,
threshold: this.threshold,
});
};
this.lastPrice = currentPrice;
}
}
alert(direction, priceStr, percentChange) {
const sign = direction === "UP" ? "+" : "-";
console.log(
`[ALERT] ${this.pair} ${direction} ${sign}${percentChange.toFixed(2)}% | Price: ${priceStr}`,
try {
await this.onAlert(alertData);
} catch (err) {
logger.error(
err,
`[${this.pair}] Failed to process alert callback: ${err.message}`,
);
}
this.lastPrice = price;
}
}
} catch (err) {
logger.error(err, `[${this.pair}] ${err.message}`);
}
if (this.running) {
this.timer = setTimeout(this.loop, this.interval);
}
};
}

View file

@ -1,11 +1,34 @@
import pg from "pg";
import { setTimeout } from "timers/promises";
import logger from "./logger.js";
const { Pool } = pg;
const pool = new Pool({
let pool = null;
export function getPool() {
if (pool) {
return pool;
}
if (!process.env.DATABASE_URL) {
return null;
}
pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
allowExitOnIdle: false,
});
pool.on("error", (err) => {
logger.error(err, "[DB] Unexpected error on idle client", err);
});
return pool;
}
const CREATE_TABLE_QUERY = `
CREATE TABLE IF NOT EXISTS alerts (
@ -22,28 +45,47 @@ const CREATE_TABLE_QUERY = `
`;
export async function initDB() {
const currentPool = getPool();
if (!currentPool) {
logger.warn("[DB] specificiation missing. Running in memory");
return;
}
const maxRetries = 10;
const retryDelay = 2000;
let delay = 2000;
for (let i = 0; i < maxRetries; i++) {
let client;
try {
await pool.query(CREATE_TABLE_QUERY);
console.log("[DB] Database initialized and connected.");
client = await currentPool.connect();
await client.query(CREATE_TABLE_QUERY);
logger.info("[DB] Database initialized and connected.");
return;
} catch (err) {
if (i === maxRetries - 1) {
console.error("[DB] Failed to connect after all retries:", err.message);
throw err;
}
console.log(
`[DB] Connection attempt ${i + 1} failed, retrying in ${retryDelay}ms...`,
logger.error(
`[DB] Could not connect: ${err.message}. Continuing without DB.`,
);
await setTimeout(retryDelay);
// Lets not try that again
pool = null;
return;
}
logger.warn(
{ attempt: i + 1, error: err.message },
`[DB] Connection failed (${i + 1}/${maxRetries}): ${err.message}`,
);
await setTimeout(delay);
delay *= 2;
} finally {
if (client) client.release();
}
}
}
export async function insertIntoDB(data) {
const currentPool = getPool();
if (!currentPool) return;
const query = `
INSERT INTO alerts (
pair, direction, previous_price, new_price, percent_change,
@ -62,9 +104,19 @@ export async function insertIntoDB(data) {
];
try {
await pool.query(query, values);
console.log(`[DB] Event saved for ${data.pair}`);
await getPool().query(query, values);
logger.info(`[DB] Event saved for ${data.pair}`);
} catch (err) {
console.error(`[DB] Failed to save alert: ${err.message}`);
// Re-throw so the caller knows there was a failure
logger.error(`[DB] Failed to save alert: ${err.message}`);
throw err;
}
}
export async function closePool() {
if (pool) {
await pool.end();
pool = null;
logger.info("[DB] Connection closed");
}
}

View file

@ -1,151 +1,253 @@
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
beforeAll,
} from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import nock from "nock";
import { BigNumber } from "bignumber.js";
import { Bot } from "../src/bot.js";
vi.mock("console", () => ({
log: vi.fn(),
error: vi.fn(),
}));
vi.mock("../src/db.js", () => ({
insertIntoDB: vi.fn(),
initDB: vi.fn(),
insertIntoDB: vi.fn().mockResolvedValue(undefined),
initDB: vi.fn().mockResolvedValue(undefined),
closePool: vi.fn().mockResolvedValue(undefined),
}));
describe("Test Suite", () => {
const PAIR = "BTC-USD";
const INTERVAL = 100;
const THRESHOLD = 0.01;
vi.mock("../src/logger.js", () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
},
}));
describe("Bot Core Functionality", () => {
const CONFIG = {
pair: "BTC-USD",
interval: 100,
threshold: 0.01,
};
let BotClass;
let bot;
let fetchRateMock;
let onAlertSpy;
beforeAll(() => {
// Ensure BigNumber config matches what we expect
BigNumber.config({
DECIMAL_PLACES: 10,
ROUNDING_MODE: BigNumber.ROUND_HALF_UP,
});
});
beforeEach(async () => {
vi.resetModules();
beforeEach(() => {
bot = new Bot(PAIR, INTERVAL, THRESHOLD);
vi.useFakeTimers();
fetchRateMock = vi.fn();
vi.doMock("../src/api.js", () => ({
fetchRate: fetchRateMock,
prefetchRates: vi.fn(),
}));
const botModule = await import("../src/bot.js");
BotClass = botModule.Bot;
onAlertSpy = vi.fn();
bot = new BotClass(CONFIG, onAlertSpy);
bot.running = false;
});
afterEach(() => {
bot.stop();
vi.runAllTimers();
vi.useRealTimers();
nock.cleanAll();
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe("Initialization and first fetch", () => {
it("should set lastPrice on first successful API call", async () => {
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { ask: "60000.50", bid: "59990.00", currency: "USD" });
describe("Initialization", () => {
it("should initialize with correct parameters", () => {
expect(bot.pair).toBe(CONFIG.pair);
expect(bot.interval).toBe(CONFIG.interval);
expect(bot.threshold).toBe(CONFIG.threshold);
expect(bot.running).toBe(false);
expect(bot.lastPrice).toBeNull();
});
await bot.check();
it("should set baseline price on first check without alerting", async () => {
fetchRateMock.mockResolvedValue(new BigNumber("60000.50"));
await bot.loop();
expect(bot.lastPrice).toBeDefined();
expect(bot.lastPrice.toFixed(2)).toBe("60000.50");
expect(onAlertSpy).not.toHaveBeenCalled();
});
});
describe("Price change detection", () => {
describe("Threshold Detection", () => {
beforeEach(async () => {
// Establish baseline
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { ask: "50000.00" });
await bot.check();
nock.cleanAll();
fetchRateMock.mockResolvedValue(new BigNumber("50000.00"));
await bot.loop();
fetchRateMock.mockReset();
});
it("should NOT alert when change is below threshold", async () => {
const alertSpy = vi.spyOn(bot, "alert");
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { ask: "50004.99" }); // +0.00998%
fetchRateMock.mockResolvedValue(new BigNumber("50004.99"));
await bot.check();
await bot.loop();
expect(alertSpy).not.toHaveBeenCalled();
expect(onAlertSpy).not.toHaveBeenCalled();
expect(bot.lastPrice.toFixed(2)).toBe("50000.00");
});
it("should alert when price moves exactly 0.01% (threshold)", async () => {
const alertSpy = vi.spyOn(bot, "alert");
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { ask: "50005.00" });
it("should alert when price moves exactly at threshold", async () => {
fetchRateMock.mockResolvedValue(new BigNumber("50005.00"));
await bot.check();
await bot.loop();
// Check specifically for arguments passed to alert
expect(alertSpy).toHaveBeenCalledWith("UP", "50005.00", 0.01);
expect(onAlertSpy).toHaveBeenCalledTimes(1);
const alertData = onAlertSpy.mock.calls[0][0];
expect(alertData).toMatchObject({
pair: "BTC-USD",
direction: "UP",
previousPrice: "50000.000000",
newPrice: "50005.000000",
});
expect(bot.lastPrice.toFixed(2)).toBe("50005.00");
});
it("should alert on downward move", async () => {
const alertSpy = vi.spyOn(bot, "alert");
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { ask: "49500.00" }); // -1%
it("should alert on downward price movement", async () => {
fetchRateMock.mockResolvedValue(new BigNumber("49995.00"));
await bot.check();
await bot.loop();
expect(alertSpy).toHaveBeenCalledWith("DOWN", "49500.00", -1);
expect(onAlertSpy).toHaveBeenCalledTimes(1);
expect(onAlertSpy.mock.calls[0][0].direction).toBe("DOWN");
});
it("should alert on large price swing", async () => {
fetchRateMock.mockResolvedValue(new BigNumber("51000.00"));
await bot.loop();
expect(onAlertSpy).toHaveBeenCalledTimes(1);
expect(onAlertSpy.mock.calls[0][0].percentChange).toBe("2.0000");
});
});
describe("Alert method", () => {
it("should log correct message for price increase", () => {
bot.lastPrice = new BigNumber("50000");
const consoleSpy = vi.spyOn(console, "log");
describe("Consecutive Alerts Logic", () => {
it("should base next alert on last ALERT price, not last CHECKED price", async () => {
fetchRateMock.mockResolvedValue(new BigNumber("50000.00"));
await bot.loop();
bot.alert("UP", "50500.00", 1);
fetchRateMock.mockResolvedValue(new BigNumber("50003.00"));
await bot.loop();
expect(onAlertSpy).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("UP"));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("1.00%"));
fetchRateMock.mockResolvedValue(new BigNumber("50005.00"));
await bot.loop();
expect(onAlertSpy).toHaveBeenCalledTimes(1);
expect(onAlertSpy.mock.calls[0][0].previousPrice).toBe("50000.000000");
expect(onAlertSpy.mock.calls[0][0].newPrice).toBe("50005.000000");
});
});
describe("Polling mechanism", () => {
it("should repeatedly call check()", async () => {
const checkSpy = vi.spyOn(bot, "check").mockResolvedValue();
bot.start();
vi.advanceTimersByTime(INTERVAL * 3);
await Promise.resolve();
expect(checkSpy).toHaveBeenCalledTimes(4);
});
describe("Error Handling", () => {
it("should handle network errors gracefully without crashing", async () => {
fetchRateMock.mockRejectedValue(new Error("Network error"));
await expect(bot.loop()).resolves.not.toThrow();
});
describe("Error handling", () => {
it("should bubble up errors when check() is called directly", async () => {
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.replyWithError("Connection Error");
it("should handle alert callback errors", async () => {
fetchRateMock.mockResolvedValue(new BigNumber("50000.00"));
await bot.loop();
await expect(bot.check()).rejects.toThrow("Connection Error");
});
fetchRateMock.mockResolvedValue(new BigNumber("51000.00"));
onAlertSpy.mockRejectedValue(new Error("DB Connection Lost"));
it("should reject invalid data formats", async () => {
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { bid: "100" });
await expect(bot.check()).rejects.toThrow("Invalid data format");
await expect(bot.loop()).resolves.not.toThrow();
});
});
});
describe("API", () => {
let api;
beforeEach(async () => {
vi.resetModules();
nock.cleanAll();
vi.doMock("../src/api.js", async () => {
return await vi.importActual("../src/api.js");
});
api = await import("../src/api.js");
});
afterEach(() => {
nock.cleanAll();
});
describe("fetchRate Caching", () => {
it("should cache successful responses", async () => {
const scope = nock("https://api.uphold.com")
.get("/v0/ticker/BTC-USD")
.reply(200, { ask: "60000" });
const price1 = await api.fetchRate("BTC-USD");
expect(price1.toFixed(0)).toBe("60000");
const price2 = await api.fetchRate("BTC-USD");
expect(price2.toFixed(0)).toBe("60000");
expect(scope.isDone()).toBe(true);
});
it("should bypass cache if forceRefresh is true", async () => {
const scope = nock("https://api.uphold.com")
.get("/v0/ticker/BTC-USD")
.times(2)
.reply(200, { ask: "60000" });
await api.fetchRate("BTC-USD");
await api.fetchRate("BTC-USD", true);
expect(scope.isDone()).toBe(true);
});
});
describe("Request Deduplication", () => {
it("should deduplicate concurrent requests", async () => {
const scope = nock("https://api.uphold.com")
.get("/v0/ticker/BTC-USD")
.delay(100)
.reply(200, { ask: "60000" });
const p1 = api.fetchRate("BTC-USD", true);
const p2 = api.fetchRate("BTC-USD", true);
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1).toEqual(r2);
expect(scope.isDone()).toBe(true);
});
});
describe("Retry Logic", () => {
it("should retry on failure", async () => {
const scope = nock("https://api.uphold.com")
.get("/v0/ticker/BTC-USD")
.replyWithError("Socket Hangup")
.get("/v0/ticker/BTC-USD")
.reply(200, { ask: "60000" });
const price = await api.fetchRate("BTC-USD", true);
expect(price.toFixed(0)).toBe("60000");
expect(scope.isDone()).toBe(true);
});
it("should throw after max retries", async () => {
const scope = nock("https://api.uphold.com")
.get("/v0/ticker/BTC-USD")
.times(3)
.replyWithError("Persistent Fail");
try {
await api.fetchRate("BTC-USD", true);
expect.fail("Should have thrown error");
} catch (err) {
expect(err.message).toContain("Persistent Fail");
}
expect(scope.isDone()).toBe(true);
}, 10000);
});
});