Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic rate limiting options #373

Merged
merged 63 commits into from
Aug 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
ddfc856
Simplify Xray clients
csvtuda Aug 4, 2024
13fc445
Use correct request method
csvtuda Aug 4, 2024
bcc4513
Add test case
csvtuda Aug 4, 2024
5a0ed3f
Add test cases
csvtuda Aug 4, 2024
2e898bb
Refactor multipart info
csvtuda Aug 4, 2024
5bfd4f4
Rename file
csvtuda Aug 4, 2024
cf4797f
Move files
csvtuda Aug 4, 2024
16b8e92
Rename classes
csvtuda Aug 4, 2024
cad9a39
Include begin and end date in multipart info
csvtuda Aug 4, 2024
8188bfa
Include begin and end date in conversion
csvtuda Aug 4, 2024
7231169
Split source and destination vertex types
csvtuda Aug 4, 2024
851c38c
Add multipart info conversion to graph
csvtuda Aug 4, 2024
657890b
Add summary extraction
csvtuda Aug 4, 2024
d836d3d
Progress towards arbitrary Jira fields
csvtuda Aug 5, 2024
0e25256
Implement multipart formdata logging
csvtuda Aug 6, 2024
fdba814
Refactor request logging
csvtuda Aug 6, 2024
7d762bc
Improve logged request names
csvtuda Aug 6, 2024
90642ad
Fix type issues
csvtuda Aug 8, 2024
50c4135
Fix tests
csvtuda Aug 8, 2024
ebd88e5
Remove unused Cucumber parameter
csvtuda Aug 8, 2024
9f3732f
Improve utility error readability
csvtuda Aug 8, 2024
9cc97b6
Fix graph test
csvtuda Aug 8, 2024
a699eaf
Fix graph test
csvtuda Aug 8, 2024
191e837
Fix graph test
csvtuda Aug 8, 2024
53cc87c
Fix graph test
csvtuda Aug 8, 2024
7b81789
Fix graph test
csvtuda Aug 8, 2024
cbec468
Fix graph test
csvtuda Aug 8, 2024
b9702fb
Fix graph test
csvtuda Aug 8, 2024
90b87f6
Fix graph test
csvtuda Aug 8, 2024
31b0d94
Fix graph test
csvtuda Aug 8, 2024
be6b425
Fix graph test
csvtuda Aug 8, 2024
bc9444d
Fix graph test
csvtuda Aug 8, 2024
264df88
Fix graph test
csvtuda Aug 8, 2024
b646e44
Fix graph test
csvtuda Aug 8, 2024
90f3aa5
Fix graph test
csvtuda Aug 8, 2024
97dcefd
Fix test
csvtuda Aug 8, 2024
d396ce0
Increase test coverage
csvtuda Aug 9, 2024
bb1b9db
Allow more than just fields in execution issue data
csvtuda Aug 9, 2024
65f9cf9
Simplify summary handling
csvtuda Aug 10, 2024
265417e
Fix directory structure
csvtuda Aug 10, 2024
69dc535
Deprecate labels and description
csvtuda Aug 10, 2024
c50399d
Update multipart creation
csvtuda Aug 10, 2024
94d5ce6
Add multipart conversion tests
csvtuda Aug 10, 2024
53a69ee
Remove only
csvtuda Aug 10, 2024
20a299a
Add tests
csvtuda Aug 10, 2024
8599f6d
Add request tests
csvtuda Aug 10, 2024
14afecc
Update context
csvtuda Aug 10, 2024
81560cd
Fix axios tests
csvtuda Aug 10, 2024
7a677ad
Fix timezone issues in multipart test
csvtuda Aug 10, 2024
82be8a9
Add file size configurability
csvtuda Aug 10, 2024
bcd9f89
Fix request interception tests
csvtuda Aug 10, 2024
8b417ae
Add basic rate limiting functionality
csvtuda Aug 11, 2024
16b6b5f
Add comment
csvtuda Aug 11, 2024
80cc4b3
Support rate limiting specification
csvtuda Aug 11, 2024
6b08e37
Improve rate limiting documentation
csvtuda Aug 11, 2024
0d7a659
Simplify rate limiting
csvtuda Aug 11, 2024
c90d657
Fix test
csvtuda Aug 11, 2024
b6adf18
Account for network latency in test
csvtuda Aug 11, 2024
5042083
Use real file for form data test
csvtuda Aug 11, 2024
0662e16
Fix server response
csvtuda Aug 11, 2024
1258ea7
Prepare different future rate limiting schemes
csvtuda Aug 11, 2024
8bad0d2
Merge branch 'main' into feature/rate-limiting
csvtuda Aug 11, 2024
e6a625f
Fix merge conflict
csvtuda Aug 11, 2024
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
687 changes: 1 addition & 686 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 5 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@
"lib": "src"
},
"scripts": {
"test": "npm-run-all --parallel --race start:mock-server start:unit-tests",
"test": "npm-run-all --parallel --race start:test:server start:test:unit",
"test:coverage": "nyc npm run test",
"test:integration": "npm-run-all --parallel --race start:mock-server start:integration-tests",
"start:mock-server": "serve -p 8080 --no-request-logging",
"start:unit-tests": "mocha --ignore 'test/integration/**/*'",
"start:integration-tests": "mocha -- --timeout 180000 --ignore 'src/**/*' --ignore index.spec.ts test/integration",
"test:integration": "npm-run-all --parallel --race start:test:server start:test:integration",
"start:test:server": "ts-node test/server.ts",
"start:test:unit": "mocha --ignore 'test/integration/**/*'",
"start:test:integration": "mocha -- --timeout 180000 --ignore 'src/**/*' --ignore index.spec.ts test/integration",
"build": "tsc --project tsconfigBuild.json && shx cp package.json README.md LICENSE.md CHANGELOG.md dist/",
"eslint": "eslint src/**/*.ts test/**/*.ts index.ts index.spec.ts"
},
Expand Down Expand Up @@ -81,7 +81,6 @@
"mocha": "^10.5.2",
"npm-run-all": "^4.1.5",
"nyc": "^17.0.0",
"serve": "^14.2.3",
"shx": "^0.3.4",
"sinon": "^17.0.1",
"sinon-chai": "^3.7.0",
Expand Down
72 changes: 66 additions & 6 deletions src/client/https/requests.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import * as BaseAxios from "axios";
import chai, { expect } from "chai";
import chaiAsPromised from "chai-as-promised";
import FormData from "form-data";
import { Readable } from "node:stream";
import { createReadStream } from "node:fs";
import path from "path";
import { stub, useFakeTimers } from "sinon";
import { getMockedLogger } from "../../../test/mocks";
import { LOCAL_SERVER } from "../../../test/server";
import { LOCAL_SERVER } from "../../../test/server-config";
import { Level } from "../../util/logging";
import { AxiosRestClient } from "./requests";

Expand Down Expand Up @@ -336,14 +336,15 @@ describe(path.relative(process.cwd(), __filename), () => {

it("logs formdata only up to a certain length", async () => {
const logger = getMockedLogger();
const restClient = new AxiosRestClient({ debug: true, fileSizeLimit: 1 });
const buffer = Buffer.alloc(1024 * 1024 * 1, ".");
const restClient = new AxiosRestClient({ debug: true, fileSizeLimit: 0.5 });
const formdata = new FormData();
formdata.append("long.txt", Readable.from(buffer));
formdata.append("long.txt", createReadStream("./test/resources/big.txt"));
await restClient.post(`http://${LOCAL_SERVER.url}`, formdata, {
headers: { ...formdata.getHeaders() },
});
expect(logger.logToFile.firstCall.args[0]).to.contain("[... omitted due to file size]");
// The 'end' event is emitted after the response has arrived.
await new Promise((resolve) => setTimeout(resolve, 100));
expect(logger.logToFile.secondCall.args[0]).to.contain("[... omitted due to file size]");
});

it("logs requests happening at the same time", async () => {
Expand All @@ -363,4 +364,63 @@ describe(path.relative(process.cwd(), __filename), () => {
`0${date.getHours().toString()}_00_12_GET_http_localhost_8080_request_1.json`
);
});

it("does not rate limit requests by default", async () => {
const restClient = new AxiosRestClient();
const responses = await Promise.all([
restClient.get(`http://${LOCAL_SERVER.url}`),
restClient.get(`http://${LOCAL_SERVER.url}`),
restClient.get(`http://${LOCAL_SERVER.url}`),
restClient.get(`http://${LOCAL_SERVER.url}`),
restClient.get(`http://${LOCAL_SERVER.url}`),
restClient.get(`http://${LOCAL_SERVER.url}`),
restClient.get(`http://${LOCAL_SERVER.url}`),
restClient.get(`http://${LOCAL_SERVER.url}`),
restClient.get(`http://${LOCAL_SERVER.url}`),
restClient.get(`http://${LOCAL_SERVER.url}`),
]);
/* eslint-disable @typescript-eslint/no-unsafe-argument */
const dateHeader0 = new Date(Number.parseInt(responses[0].headers["x-response-time"]));
const dateHeader1 = new Date(Number.parseInt(responses[1].headers["x-response-time"]));
const dateHeader2 = new Date(Number.parseInt(responses[2].headers["x-response-time"]));
const dateHeader3 = new Date(Number.parseInt(responses[3].headers["x-response-time"]));
const dateHeader4 = new Date(Number.parseInt(responses[4].headers["x-response-time"]));
const dateHeader5 = new Date(Number.parseInt(responses[5].headers["x-response-time"]));
const dateHeader6 = new Date(Number.parseInt(responses[6].headers["x-response-time"]));
const dateHeader7 = new Date(Number.parseInt(responses[7].headers["x-response-time"]));
const dateHeader8 = new Date(Number.parseInt(responses[8].headers["x-response-time"]));
const dateHeader9 = new Date(Number.parseInt(responses[9].headers["x-response-time"]));
/* eslint-enable @typescript-eslint/no-unsafe-argument */
expect(dateHeader1.getTime() - dateHeader0.getTime()).to.be.approximately(0, 50);
expect(dateHeader2.getTime() - dateHeader1.getTime()).to.be.approximately(0, 50);
expect(dateHeader3.getTime() - dateHeader2.getTime()).to.be.approximately(0, 50);
expect(dateHeader4.getTime() - dateHeader3.getTime()).to.be.approximately(0, 50);
expect(dateHeader5.getTime() - dateHeader4.getTime()).to.be.approximately(0, 50);
expect(dateHeader6.getTime() - dateHeader5.getTime()).to.be.approximately(0, 50);
expect(dateHeader7.getTime() - dateHeader6.getTime()).to.be.approximately(0, 50);
expect(dateHeader8.getTime() - dateHeader7.getTime()).to.be.approximately(0, 50);
expect(dateHeader9.getTime() - dateHeader8.getTime()).to.be.approximately(0, 50);
}).timeout(3000);

it("rate limits requests", async () => {
const restClient = new AxiosRestClient({ rateLimiting: { requestsPerSecond: 2 } });
const responses = await Promise.all([
restClient.get(`http://${LOCAL_SERVER.url}`),
restClient.get(`http://${LOCAL_SERVER.url}`),
restClient.get(`http://${LOCAL_SERVER.url}`),
restClient.get(`http://${LOCAL_SERVER.url}`),
restClient.get(`http://${LOCAL_SERVER.url}`),
]);
/* eslint-disable @typescript-eslint/no-unsafe-argument */
const dateHeader0 = new Date(Number.parseInt(responses[0].headers["x-response-time"]));
const dateHeader1 = new Date(Number.parseInt(responses[1].headers["x-response-time"]));
const dateHeader2 = new Date(Number.parseInt(responses[2].headers["x-response-time"]));
const dateHeader3 = new Date(Number.parseInt(responses[3].headers["x-response-time"]));
const dateHeader4 = new Date(Number.parseInt(responses[4].headers["x-response-time"]));
/* eslint-enable @typescript-eslint/no-unsafe-argument */
expect(dateHeader1.getTime() - dateHeader0.getTime()).to.be.approximately(500, 50);
expect(dateHeader2.getTime() - dateHeader1.getTime()).to.be.approximately(500, 50);
expect(dateHeader3.getTime() - dateHeader2.getTime()).to.be.approximately(500, 50);
expect(dateHeader4.getTime() - dateHeader3.getTime()).to.be.approximately(500, 50);
}).timeout(3000);
});
61 changes: 56 additions & 5 deletions src/client/https/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,38 @@ export interface RequestsOptions {
* Additional options for controlling HTTP behaviour.
*/
http?: AxiosRequestConfig;
/**
* Request rate limiting options for this client. If configured, each of the client's requests
* can be delayed by a certain amount of time depending on the configured rate limiting scheme.
*/
rateLimiting?: {
/**
* Avoids rate limits by delaying requests if sending them would exceed the specified
* requests per second.
*
* @example
*
*
* ```ts
* // Will send a request at most every 500 milliseconds.
* {
* rateLimiting: {
* requestsPerSecond: 2
* }
* }
* ```
*
* ```ts
* // Will send a request at most every 5 seconds.
* {
* rateLimiting: {
* requestsPerSecond: 0.2
* }
* }
* ```
*/
requestsPerSecond?: number;
};
}

/**
Expand All @@ -62,19 +94,22 @@ export interface LoggedRequest {

export class AxiosRestClient {
private readonly options: RequestsOptions | undefined;

private axios: AxiosInstance | undefined = undefined;
private readonly createdLogFiles: Map<string, number>;
private axios: AxiosInstance | undefined;
private lastRequestTime: number | undefined;

constructor(options?: RequestsOptions) {
this.options = options;
this.createdLogFiles = new Map();
this.axios = undefined;
this.lastRequestTime = undefined;
}

public async get<R>(
url: string,
config?: AxiosRequestConfig<unknown>
): Promise<AxiosResponse<R>> {
await this.delayIfNeeded();
const progressInterval = this.startResponseInterval(url);
try {
return await this.getAxios().get(url, {
Expand All @@ -91,6 +126,7 @@ export class AxiosRestClient {
data?: D,
config?: AxiosRequestConfig<D>
): Promise<AxiosResponse<R>> {
await this.delayIfNeeded();
const progressInterval = this.startResponseInterval(url);
try {
return await this.getAxios().post(url, data, {
Expand All @@ -107,6 +143,7 @@ export class AxiosRestClient {
data?: D,
config?: AxiosRequestConfig<D>
): Promise<AxiosResponse<R>> {
await this.delayIfNeeded();
const progressInterval = this.startResponseInterval(url);
try {
return await this.getAxios().put(url, data, {
Expand Down Expand Up @@ -196,9 +233,6 @@ export class AxiosRestClient {
);
LOG.message(Level.DEBUG, `Request: ${resolvedFilename}`);
});
formData.on("error", (error) => {
throw error;
});
} else {
const resolvedFilename = LOG.logToFile(
JSON.stringify(
Expand Down Expand Up @@ -290,6 +324,23 @@ export class AxiosRestClient {
return filename;
}
}

private async delayIfNeeded(): Promise<void> {
// We specifically do not use axios interceptors here because we would need to handle
// connection timeouts, ECONNRESET etc. otherwise (I think).
if (this.options?.rateLimiting?.requestsPerSecond) {
const interval = 1000 / this.options.rateLimiting.requestsPerSecond;
const now = Date.now();
const nextRequestTime = this.lastRequestTime ? this.lastRequestTime + interval : now;
this.lastRequestTime = nextRequestTime;
const delay = nextRequestTime - now;
if (delay > 0) {
await new Promise((resolve) => {
setTimeout(resolve, delay);
});
}
}
}
}

function dateToTimestamp(date: Date): string {
Expand Down
73 changes: 70 additions & 3 deletions src/context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1275,7 +1275,11 @@ describe(path.relative(process.cwd(), __filename), () => {
const httpClients = initHttpClients(undefined, {});
expect(httpClients.jira).to.eq(httpClients.xray);
expect(httpClients.jira).to.deep.eq(
new AxiosRestClient({ debug: undefined, http: {} })
new AxiosRestClient({
debug: undefined,
http: {},
rateLimiting: undefined,
})
);
});
it("creates a single client using a single config", () => {
Expand All @@ -1296,6 +1300,7 @@ describe(path.relative(process.cwd(), __filename), () => {
port: 12345,
},
},
rateLimiting: undefined,
})
);
});
Expand All @@ -1319,16 +1324,18 @@ describe(path.relative(process.cwd(), __filename), () => {
port: 12345,
},
},
rateLimiting: undefined,
})
);
expect(httpClients.xray).to.deep.eq(
new AxiosRestClient({
debug: undefined,
http: {},
rateLimiting: undefined,
})
);
});
it("creates a different xray client if a xray config is passed", () => {
it("creates a different xray client if an xray config is passed", () => {
const httpOptions: InternalHttpOptions = {
xray: {
proxy: {
Expand All @@ -1343,6 +1350,7 @@ describe(path.relative(process.cwd(), __filename), () => {
new AxiosRestClient({
debug: undefined,
http: {},
rateLimiting: undefined,
})
);
expect(httpClients.xray).to.deep.eq(
Expand All @@ -1354,10 +1362,11 @@ describe(path.relative(process.cwd(), __filename), () => {
port: 12345,
},
},
rateLimiting: undefined,
})
);
});
it("creates different client if individual configs are passed", () => {
it("creates different clients if individual configs are passed", () => {
const httpOptions: InternalHttpOptions = {
jira: {
proxy: {
Expand All @@ -1383,6 +1392,7 @@ describe(path.relative(process.cwd(), __filename), () => {
port: 98765,
},
},
rateLimiting: undefined,
})
);
expect(httpClients.xray).to.deep.eq(
Expand All @@ -1394,6 +1404,7 @@ describe(path.relative(process.cwd(), __filename), () => {
port: 12345,
},
},
rateLimiting: undefined,
})
);
});
Expand All @@ -1405,6 +1416,7 @@ describe(path.relative(process.cwd(), __filename), () => {
port: 98765,
},
},
rateLimiting: { requestsPerSecond: 5 },
timeout: 42,
xray: {
proxy: {
Expand All @@ -1425,6 +1437,7 @@ describe(path.relative(process.cwd(), __filename), () => {
},
timeout: 42,
},
rateLimiting: { requestsPerSecond: 5 },
})
);
expect(httpClients.xray).to.deep.eq(
Expand All @@ -1437,6 +1450,60 @@ describe(path.relative(process.cwd(), __filename), () => {
},
timeout: 42,
},
rateLimiting: { requestsPerSecond: 5 },
})
);
});
it("prefers individual http options to common ones", () => {
const httpOptions: InternalHttpOptions = {
jira: {
proxy: {
host: "http://localhost1",
port: 9999,
},
rateLimiting: { requestsPerSecond: 20 },
timeout: 500,
},
proxy: {
host: "http://localhost2",
port: 5555,
},
rateLimiting: { requestsPerSecond: 10 },
timeout: 42,
xray: {
proxy: {
host: "http://localhost3",
port: 1111,
},
rateLimiting: { requestsPerSecond: 1 },
timeout: 10000,
},
};
const httpClients = initHttpClients(undefined, httpOptions);
expect(httpClients.jira).to.deep.eq(
new AxiosRestClient({
debug: undefined,
http: {
proxy: {
host: "http://localhost1",
port: 9999,
},
timeout: 500,
},
rateLimiting: { requestsPerSecond: 20 },
})
);
expect(httpClients.xray).to.deep.eq(
new AxiosRestClient({
debug: undefined,
http: {
proxy: {
host: "http://localhost3",
port: 1111,
},
timeout: 10000,
},
rateLimiting: { requestsPerSecond: 1 },
})
);
});
Expand Down
Loading