Skip to content
This repository was archived by the owner on Apr 22, 2026. It is now read-only.

Commit 39735ee

Browse files
author
Iztok
committed
multi http api unit tests
1 parent f1900a4 commit 39735ee

7 files changed

Lines changed: 332 additions & 31 deletions

File tree

packages/fasset-bots-core/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,12 @@
121121
"@types/chai": "4.3.6",
122122
"@types/chai-as-promised": "7.1.6",
123123
"@types/chai-spies": "1.0.4",
124+
"@types/express": "^4.17.18",
124125
"axios-mock-adapter": "1.22.0",
125126
"chai": "4.5.0",
126127
"chai-as-promised": "7.1.1",
127128
"chai-spies": "1.0.0",
129+
"express": "4.18.2",
128130
"hardhat": "2.24.3",
129131
"rewire": "7.0.0",
130132
"typechain": "8.3.0",

packages/fasset-bots-core/src/utils/HttpApiClient.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import { logger } from "./logger";
66

77
export const DEFAULT_TIMEOUT = 15_000;
88

9-
export class ApiNetworkError extends ErrorWithCause {}
9+
export class ApiBaseError extends ErrorWithCause {}
1010

11-
export class ApiServiceError extends ErrorWithCause {
11+
export class ApiServiceError extends ApiBaseError {
1212
#response: AxiosResponse<unknown>;
1313

1414
constructor(message: string, response: AxiosResponse<unknown>, cause: AxiosError) {
@@ -19,6 +19,11 @@ export class ApiServiceError extends ErrorWithCause {
1919
get response() { return this.#response; }
2020
}
2121

22+
export class ApiNetworkError extends ApiBaseError {}
23+
export class ApiTimeoutError extends ApiBaseError {}
24+
export class ApiCanceledError extends ApiBaseError {}
25+
export class ApiUnexpectedError extends ApiBaseError {}
26+
2227
export class HttpApiClient {
2328
constructor(
2429
public serviceName: string,
@@ -37,11 +42,11 @@ export class HttpApiClient {
3742
return await this.request("GET", url, undefined, methodName, requestId, abortSignal);
3843
}
3944

40-
async post<R>(url: string, data: any, methodName: string, requestId: number, abortSignal?: AbortSignal): Promise<R> {
45+
async post<R, D = unknown>(url: string, data: D, methodName: string, requestId: number, abortSignal?: AbortSignal): Promise<R> {
4146
return await this.request("POST", url, data, methodName, requestId, abortSignal);
4247
}
4348

44-
async request<R>(httpMethod: Method, url: string, data: any, methodName: string, requestId: number, abortSignal?: AbortSignal): Promise<R> {
49+
async request<R, D = unknown>(httpMethod: Method, url: string, data: D, methodName: string, requestId: number, abortSignal?: AbortSignal): Promise<R> {
4550
const requestInfo = `request[${requestId}] client[${this.serverIndex}] ${this.serviceName}.${methodName}`;
4651
logger.info(`START ${requestInfo}: ${httpMethod.toUpperCase()} ${this.client.getUri()}${url}`);
4752
const startTimestamp = Date.now();
@@ -57,25 +62,35 @@ export class HttpApiClient {
5762
logger.info(`SUCCESS ${requestInfo} (${elapsedSec(startTimestamp)}s): [${response.status} ${response.statusText}]`);
5863
return response.data;
5964
} catch (error) {
60-
if (isAxiosError(error) && error.response) {
65+
if (isAxiosError(error)) {
6166
const message = clipText(error.message, 120);
6267
if (error.response) {
6368
const response = error.response;
6469
const responseText = clipText(typeof response.data === "string" ? response.data : tryJsonStringify(response.data), 160);
6570
logger.error(`SERVICE ERROR ${requestInfo} (${elapsedSec(startTimestamp)}s): [${response.status} ${response.statusText}] ${message}\n ${responseText}`);
6671
throw new ApiServiceError(`${this.serviceName}.${methodName}: ${message}`, response, error);
72+
} else if (error.name === "CanceledError") {
73+
if (Date.now() - startTimestamp < this.timeout) {
74+
logger.info(`CANCELED ${requestInfo} (${elapsedSec(startTimestamp)}s): ${message}`);
75+
throw new ApiCanceledError(`${this.serviceName}.${methodName}: ${message}`, error);
76+
} else {
77+
logger.error(`TIMEOUT ERROR ${requestInfo} (${elapsedSec(startTimestamp)}s): ${message}`);
78+
throw new ApiTimeoutError(`${this.serviceName}.${methodName}: ${message}`, error);
79+
}
80+
} else if (error.name === "AxiosError") {
81+
if (error.message?.match(/^timeout of \w* exceeded$/)) {
82+
logger.error(`TIMEOUT ERROR ${requestInfo} (${elapsedSec(startTimestamp)}s): ${message}`);
83+
throw new ApiTimeoutError(`${this.serviceName}.${methodName}: ${message}`, error);
84+
} else {
85+
logger.error(`NETWORK ERROR ${requestInfo} (${elapsedSec(startTimestamp)}s): ${message}`);
86+
throw new ApiNetworkError(`${this.serviceName}.${methodName}: ${message}`, error);
87+
}
6788
}
68-
logger.error(`NETWORK ERROR ${requestInfo} (${elapsedSec(startTimestamp)}s): ${message}`);
69-
throw new ApiNetworkError(`${this.serviceName}.${methodName}: ${message}`, error);
89+
// other error types treated as unexpected, even if they are axios errors
7090
}
7191
const message = clipText(String(error), 120);
72-
const elapsedMs = Date.now() - startTimestamp;
73-
if (error instanceof Error && error.name === "CanceledError" && elapsedMs < this.timeout) {
74-
logger.info(`CANCELED ${requestInfo} (${elapsedSec(startTimestamp)}s): ${error.message}`);
75-
} else {
76-
logger.error(`UNEXPECTED ERROR ${requestInfo} (${elapsedSec(startTimestamp)}s): ${message}`);
77-
}
78-
throw new ApiNetworkError(`${this.serviceName}.${methodName}: UNEXPECTED ${message}`, error);
92+
logger.error(`UNEXPECTED ERROR ${requestInfo} (${elapsedSec(startTimestamp)}s): ${message}`);
93+
throw new ApiUnexpectedError(`${this.serviceName}.${methodName}: ${message}`, error);
7994
}
8095
}
8196

packages/fasset-bots-core/src/utils/MultiApiClient.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Method } from "axios";
22
import { ErrorWithCause } from "./ErrorWithCause";
33
import { abortableSleep } from "./helpers";
4-
import { ApiNetworkError, ApiServiceError, DEFAULT_TIMEOUT, HttpApiClient } from "./HttpApiClient";
4+
import { ApiServiceError, ApiTimeoutError, DEFAULT_TIMEOUT, HttpApiClient } from "./HttpApiClient";
55
import { logger } from "./logger";
66

77
const PARALLEL = true;
@@ -48,7 +48,7 @@ export abstract class MultiApiClient {
4848
tryNextAfter: number = DEFAULT_TRY_NEXT_AFTER, // start request with next client in parallel after this time
4949
timeout: number = DEFAULT_TIMEOUT, // axios http request timeout
5050
killAfter: number = DEFAULT_KILL_AFTER, // stop waiting after this many seconds, even if there is no timeout from axios
51-
) {
51+
): MultiApiClient {
5252
if (parallel) {
5353
return new MultiApiClientParallel(serviceName, tryNextAfter, timeout, killAfter);
5454
} else {
@@ -65,15 +65,15 @@ export abstract class MultiApiClient {
6565
return await this.request("GET", url, undefined, methodName);
6666
}
6767

68-
async post<R>(url: string, data: any, methodName: string): Promise<R> {
68+
async post<R, D = unknown>(url: string, data: D, methodName: string): Promise<R> {
6969
return await this.request("POST", url, data, methodName);
7070
}
7171

72-
abstract request<R>(httpMethod: Method, url: string, data: any, methodName: string): Promise<R>;
72+
abstract request<R, D = unknown>(httpMethod: Method, url: string, data: D, methodName: string): Promise<R>;
7373
}
7474

7575
class MultiApiClientSerial extends MultiApiClient {
76-
override async request<R>(httpMethod: Method, url: string, data: any, methodName: string): Promise<R> {
76+
override async request<R, D = unknown>(httpMethod: Method, url: string, data: D, methodName: string): Promise<R> {
7777
const requestId = HttpApiClient.newRequestId();
7878
const clients = Array.from(this.clients);
7979
if (clients.length === 0) {
@@ -94,7 +94,7 @@ class MultiApiClientSerial extends MultiApiClient {
9494
}
9595

9696
class MultiApiClientParallel extends MultiApiClient {
97-
override async request<R>(httpMethod: Method, url: string, data: any, methodName: string): Promise<R> {
97+
override async request<R, D = unknown>(httpMethod: Method, url: string, data: D, methodName: string): Promise<R> {
9898
const requestId = HttpApiClient.newRequestId();
9999
const clients = Array.from(this.clients);
100100
if (clients.length === 0) {
@@ -103,19 +103,23 @@ class MultiApiClientParallel extends MultiApiClient {
103103
const abortController = new AbortController();
104104
const abortSignal = abortController.signal;
105105
const results = await Promise.allSettled(clients.map(async (client, index) => {
106-
try {
107-
await abortableSleep(index * this.tryNextAfter, abortSignal);
108-
return await Promise.race([
109-
client.request<R>(httpMethod, url, data, methodName, requestId, abortSignal),
110-
abortableSleep(this.killAfter, abortSignal)
111-
.then(() => { throw new ApiNetworkError(`Timeout of ${this.killAfter}ms reached`, null); }),
112-
]);
113-
} finally {
114-
if (!abortSignal.aborted) {
115-
abortController.abort(new Error("Request aborted because it finished first on another client"));
116-
}
106+
await abortableSleep(index * this.tryNextAfter, abortSignal);
107+
const result = await Promise.race([
108+
client.request<R>(httpMethod, url, data, methodName, requestId, abortSignal),
109+
abortableSleep(this.killAfter, abortSignal)
110+
.then(() => { throw new ApiTimeoutError(`${this.serviceName}.${methodName}: Timeout of ${this.killAfter}ms reached`, null); }),
111+
]);
112+
// on success, cancel attempts on other clients
113+
if (!abortSignal.aborted) {
114+
abortController.abort(new Error("Request aborted because it finished first on another client"));
117115
}
116+
return result;
118117
}));
118+
// cancel requests that are hanging after timeouts expired
119+
if (!abortSignal.aborted) {
120+
abortController.abort(new Error("Request aborted because it finished first on another client"));
121+
}
122+
// find successful response, if any exists
119123
const successfulResult = results.find(res => res.status === "fulfilled");
120124
if (successfulResult) {
121125
return successfulResult.value;
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import express from "express";
2+
import type { Server } from "node:http";
3+
import { elapsedSec, sleep } from "../../src/utils/helpers";
4+
5+
type AppData = { index: number };
6+
7+
function createApp(data?: AppData) {
8+
const app = express();
9+
app.use(express.json());
10+
11+
app.get("/wait/:seconds", (req, res) => runAsyncBody(res, async () => {
12+
const start = Date.now();
13+
const delayMs = Number(req.params.seconds) * 1000;
14+
await sleep(delayMs);
15+
return { elapsed: elapsedSec(start) };
16+
}));
17+
18+
app.post("/error", (req, res) => runAsyncBody(res, async () => {
19+
const body = req.body;
20+
throw new Error(`text=${body?.text}`);
21+
}));
22+
23+
app.post("/multiwait", (req, res) => runAsyncBody(res, async () => {
24+
const start = Date.now();
25+
const delaysMs = (req.body as number[]).map(s => s * 1000);
26+
await sleep(delaysMs[data?.index ?? 0]);
27+
return { elapsed: elapsedSec(start), index: data?.index };
28+
}));
29+
30+
app.post("/multierror", (req, res) => runAsyncBody(res, async () => {
31+
const doError = (req.body as number[]).includes(data?.index ?? -1);
32+
if (doError) {
33+
throw new Error(`index=${data?.index}`);
34+
} else {
35+
return { status: "OK", index: data?.index };
36+
}
37+
}));
38+
39+
return app;
40+
}
41+
42+
function runAsyncBody(res: express.Response, handler: () => Promise<unknown>) {
43+
handler()
44+
.then(v => {
45+
res.json(v);
46+
})
47+
.catch(e => {
48+
res.status(400);
49+
res.json({ error: String(e) });
50+
})
51+
}
52+
53+
export function startHttpListening(host: string, port: number, data?: AppData) {
54+
return new Promise<Server>((resolve) => {
55+
const app = createApp(data);
56+
const server = app.listen(port, host, () => {
57+
console.log(`Http server listening on http://${host}:${port}`);
58+
resolve(server);
59+
});
60+
});
61+
}
62+
63+
export function stopHttpListening(server: Server | undefined) {
64+
return new Promise<void>((resolve) => {
65+
if (server) {
66+
server.close(() => {
67+
console.log("Http server stopped");
68+
resolve();
69+
});
70+
} else {
71+
resolve();
72+
}
73+
});
74+
}
75+
76+
if (typeof require !== "undefined" && require.main === module) {
77+
void startHttpListening("127.0.0.1", 8080);
78+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { assert, expect, use } from "chai";
2+
import chaiAsPromised from "chai-as-promised";
3+
import { Server } from "node:http";
4+
import { ApiCanceledError, ApiNetworkError, ApiServiceError, ApiTimeoutError, ApiUnexpectedError, HttpApiClient } from "../../../src/utils/HttpApiClient";
5+
import { startHttpListening, stopHttpListening } from "../../test-utils/test-api-server";
6+
use(chaiAsPromised);
7+
8+
describe("Test http server timeouts and errors", () => {
9+
const testHost = "127.0.0.1";
10+
const testPort = 8080;
11+
const serverName = "TestService";
12+
const testUrl = `http://${testHost}:${testPort}`;
13+
let server: Server;
14+
15+
before(async () => {
16+
server = await startHttpListening(testHost, testPort);
17+
});
18+
19+
after(async () => {
20+
await stopHttpListening(server);
21+
});
22+
23+
it("test http server successful wait", async () => {
24+
const server = HttpApiClient.create(serverName, 0, testUrl, undefined, 2000);
25+
const a = await server.get<{ elapsed: number }>("/wait/1", "wait", 1);
26+
assert.typeOf(a.elapsed, "number");
27+
assert.isAtLeast(a.elapsed, 1);
28+
});
29+
30+
it("test http server default timeout/abort (don't know which)", async () => {
31+
const server = HttpApiClient.create(serverName, 0, testUrl, undefined, 1000);
32+
await expect(server.get<{ elapsed: number }>("/wait/3", "wait", 2))
33+
.eventually.rejectedWith(ApiTimeoutError, /canceled|timeout/);
34+
});
35+
36+
it("test http server timeout", async () => {
37+
const server = HttpApiClient.create(serverName, 0, testUrl, undefined, 1000);
38+
const abortSignal = AbortSignal.timeout(5000);
39+
await expect(server.get<{ elapsed: number }>("/wait/3", "wait", 3, abortSignal))
40+
.eventually.rejectedWith(ApiTimeoutError, /TestService.wait: timeout of 1000ms exceeded/);
41+
});
42+
43+
it("test http server default cancelation (abort before timeout expires)", async () => {
44+
const server = HttpApiClient.create(serverName, 0, testUrl, undefined, 5000);
45+
const abortSignal = AbortSignal.timeout(1000);
46+
await expect(server.get<{ elapsed: number }>("/wait/3", "wait", 4, abortSignal))
47+
.eventually.rejectedWith(ApiCanceledError, /TestService.wait: canceled/);
48+
});
49+
50+
it("test http server service error", async () => {
51+
const server = HttpApiClient.create(serverName, 0, testUrl, undefined, 5000);
52+
await expect(server.post("/error", { text: "ABCD" }, "error", 5))
53+
.eventually.rejectedWith(ApiServiceError, /TestService.error: Request failed with status code 400/);
54+
});
55+
56+
it("test http server 404 error", async () => {
57+
const server = HttpApiClient.create(serverName, 0, testUrl, undefined, 5000);
58+
await expect(server.get("/wrong_path", "wrong_path", 5))
59+
.eventually.rejectedWith(ApiServiceError, /TestService.wrong_path: Request failed with status code 404/);
60+
});
61+
62+
it("test http invalid url (no 'http://' prefix) - should throw unexpected error", async () => {
63+
const server = HttpApiClient.create(serverName, 0, `127.0.0.1:${testPort}`, undefined, 2000);
64+
await expect(server.get<{ elapsed: number }>("/wait/1", "wait", 6))
65+
.eventually.rejectedWith(ApiUnexpectedError, /TestService.wait/);
66+
});
67+
});

0 commit comments

Comments
 (0)