Skip to content

Commit 093acc1

Browse files
committed
Limited requests count to 10 in 1 minute
1 parent 342cb08 commit 093acc1

3 files changed

Lines changed: 245 additions & 14 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import fetch from 'node-fetch';
2+
3+
import getProxiedRequest from '../../../0 - utils/getProxiedRequest';
4+
import request, {
5+
getSgZoneRequestQueueState,
6+
waitForSgZoneRequestQueueToDrain,
7+
} from '../../../0 - utils/request';
8+
9+
jest.mock('node-fetch', () => jest.fn());
10+
jest.mock('../../../0 - utils/getProxiedRequest', () => jest.fn());
11+
12+
const mockedFetch = fetch as jest.MockedFunction<typeof fetch>;
13+
const mockedGetProxiedRequest = getProxiedRequest as jest.MockedFunction<typeof getProxiedRequest>;
14+
const minuteInMs = 60 * 1000;
15+
let fakeNow = new Date('2026-01-01T00:00:00.000Z').getTime();
16+
17+
beforeEach(() => {
18+
jest.useFakeTimers({ doNotFake: ['performance'] });
19+
jest.setSystemTime(new Date(fakeNow));
20+
fakeNow += minuteInMs * 2;
21+
mockedFetch.mockReset();
22+
mockedGetProxiedRequest.mockReset();
23+
mockedGetProxiedRequest.mockResolvedValue(null);
24+
});
25+
26+
afterEach(() => {
27+
jest.useRealTimers();
28+
});
29+
30+
test('should limit sg.zone requests to 10 per minute', async () => {
31+
mockedFetch.mockResolvedValue({ status: 200 } as Response);
32+
33+
const pendingRequests = Array.from({ length: 11 }, (_item, index) => (
34+
request(`https://sg.zone/replays?p=${index + 1}`)
35+
));
36+
const firstTenRequests = pendingRequests.slice(0, 10);
37+
const eleventhRequest = pendingRequests[10];
38+
let isEleventhRequestResolved = false;
39+
40+
eleventhRequest.then(() => {
41+
isEleventhRequestResolved = true;
42+
});
43+
44+
await expect(Promise.all(firstTenRequests)).resolves.toHaveLength(10);
45+
expect(mockedFetch).toHaveBeenCalledTimes(10);
46+
expect(isEleventhRequestResolved).toBe(false);
47+
48+
jest.advanceTimersByTime(59_999);
49+
await Promise.resolve();
50+
51+
expect(mockedFetch).toHaveBeenCalledTimes(10);
52+
expect(isEleventhRequestResolved).toBe(false);
53+
54+
jest.advanceTimersByTime(1);
55+
await expect(eleventhRequest).resolves.toEqual({ status: 200 });
56+
expect(mockedFetch).toHaveBeenCalledTimes(11);
57+
});
58+
59+
test('should wait until sg.zone request queue is drained', async () => {
60+
jest.useRealTimers();
61+
mockedFetch.mockResolvedValue({ status: 200 } as Response);
62+
const pendingRequest = request('https://sg.zone/replays?p=1');
63+
64+
const waitForDrainPromise = waitForSgZoneRequestQueueToDrain();
65+
let isQueueDrained = false;
66+
67+
waitForDrainPromise.then(() => {
68+
isQueueDrained = true;
69+
});
70+
71+
await Promise.resolve();
72+
await Promise.resolve();
73+
74+
expect(isQueueDrained).toBe(false);
75+
76+
await expect(pendingRequest).resolves.toEqual({ status: 200 });
77+
await expect(waitForDrainPromise).resolves.toBeUndefined();
78+
});
79+
80+
test('should expose sg.zone queue state for diagnostics', () => {
81+
const queueState = getSgZoneRequestQueueState();
82+
83+
expect(queueState.pending).toBeGreaterThanOrEqual(0);
84+
expect(queueState.active).toBeGreaterThanOrEqual(0);
85+
expect(queueState.total).toBe(queueState.pending + queueState.active);
86+
});

src/0 - utils/request.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,101 @@ import getProxiedRequest from './getProxiedRequest';
44
import logger from './logger';
55

66
const defaultRetryCount = 3;
7+
const requestsPerMinuteLimit = 10;
8+
const minuteInMs = 60 * 1000;
9+
const sgZoneHost = 'sg.zone';
10+
const requestTimestamps: number[] = [];
11+
let slotReservationQueue: Promise<void> = Promise.resolve();
12+
let pendingSgZoneRequests = 0;
13+
let activeSgZoneRequests = 0;
14+
15+
const getSgZoneRequestQueueState = () => ({
16+
pending: pendingSgZoneRequests,
17+
active: activeSgZoneRequests,
18+
total: pendingSgZoneRequests + activeSgZoneRequests,
19+
});
20+
21+
const sleep = async (ms: number): Promise<void> => (
22+
new Promise((resolve) => {
23+
setTimeout(resolve, ms);
24+
})
25+
);
26+
27+
const isSgZoneRequest = (url: string): boolean => {
28+
try {
29+
const parsedUrl = new URL(url);
30+
31+
return parsedUrl.protocol === 'https:' && parsedUrl.hostname === sgZoneHost;
32+
} catch (err) {
33+
return false;
34+
}
35+
};
36+
37+
const clearExpiredTimestamps = (now: number): void => {
38+
while (
39+
requestTimestamps.length > 0
40+
&& (now - requestTimestamps[0]) >= minuteInMs
41+
) {
42+
requestTimestamps.shift();
43+
}
44+
};
45+
46+
const waitForSgZoneRequestSlot = async (): Promise<void> => {
47+
const now = Date.now();
48+
49+
clearExpiredTimestamps(now);
50+
51+
if (requestTimestamps.length < requestsPerMinuteLimit) {
52+
requestTimestamps.push(now);
53+
54+
return;
55+
}
56+
57+
const oldestRequestTimestamp = requestTimestamps[0];
58+
const waitTime = minuteInMs - (now - oldestRequestTimestamp);
59+
60+
await sleep(waitTime);
61+
62+
await waitForSgZoneRequestSlot();
63+
};
64+
65+
const reserveSgZoneRequestSlot = async (): Promise<void> => {
66+
const slotReservation = slotReservationQueue.then(waitForSgZoneRequestSlot);
67+
68+
slotReservationQueue = slotReservation.catch(() => undefined);
69+
70+
await slotReservation;
71+
};
72+
73+
const waitForSgZoneRequestQueueToDrain = async (): Promise<void> => {
74+
if (getSgZoneRequestQueueState().total === 0) return;
75+
76+
await sleep(20);
77+
78+
await waitForSgZoneRequestQueueToDrain();
79+
};
780

881
const request = async (
982
url: string,
1083
retryCount: number = defaultRetryCount,
1184
): Promise<Response | null> => {
85+
const isSgZoneUrl = isSgZoneRequest(url);
86+
let isSgZoneRequestActive = false;
87+
1288
try {
89+
if (isSgZoneUrl) {
90+
pendingSgZoneRequests += 1;
91+
92+
try {
93+
await reserveSgZoneRequestSlot();
94+
} finally {
95+
pendingSgZoneRequests -= 1;
96+
}
97+
98+
activeSgZoneRequests += 1;
99+
isSgZoneRequestActive = true;
100+
}
101+
13102
const proxiedResponse = await getProxiedRequest(url);
14103

15104
if (proxiedResponse) return proxiedResponse;
@@ -26,8 +115,19 @@ const request = async (
26115
throw err;
27116
}
28117

118+
if (isSgZoneRequestActive) {
119+
activeSgZoneRequests -= 1;
120+
isSgZoneRequestActive = false;
121+
}
122+
29123
return await request(url, retryCount - 1);
124+
} finally {
125+
if (isSgZoneRequestActive) {
126+
activeSgZoneRequests -= 1;
127+
}
30128
}
31129
};
32130

131+
export { getSgZoneRequestQueueState, waitForSgZoneRequestQueueToDrain };
132+
33133
export default request;

src/schedule.ts

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,61 @@ import startParsingReplays from '.';
66
import generateBasicFolders from './0 - utils/generateBasicFolders';
77
import logger from './0 - utils/logger';
88
import { tempResultsPath } from './0 - utils/paths';
9+
import {
10+
getSgZoneRequestQueueState,
11+
waitForSgZoneRequestQueueToDrain,
12+
} from './0 - utils/request';
913
import generateMaceList from './jobs/generateMaceListHTML';
1014
import generateMissionMakersList from './jobs/generateMissionMakersList';
1115
import startFetchingReplays from './jobs/prepareReplaysList';
1216

1317
generateBasicFolders();
1418

19+
let siteRequestJobsQueue: Promise<void> = Promise.resolve();
20+
21+
const formatQueueStateForLog = () => {
22+
const queueState = getSgZoneRequestQueueState();
23+
24+
return `pending=${queueState.pending}, active=${queueState.active}, total=${queueState.total}`;
25+
};
26+
27+
const runSiteRequestJob = async (
28+
jobName: string,
29+
jobCallback: () => Promise<void>,
30+
): Promise<void> => {
31+
const queuedJob = siteRequestJobsQueue.then(
32+
async () => {
33+
const waitStartTimestamp = Date.now();
34+
35+
logger.info(`[schedule][${jobName}] Waiting for sg.zone queue to drain. ${formatQueueStateForLog()}`);
36+
await waitForSgZoneRequestQueueToDrain();
37+
logger.info(
38+
`[schedule][${jobName}] Queue drained after ${Date.now() - waitStartTimestamp}ms. ${formatQueueStateForLog()}. Starting job.`,
39+
);
40+
await jobCallback();
41+
logger.info(`[schedule][${jobName}] Job finished. ${formatQueueStateForLog()}`);
42+
},
43+
);
44+
45+
siteRequestJobsQueue = queuedJob.catch(() => undefined);
46+
47+
await queuedJob;
48+
};
49+
1550
Cron(
16-
'*/5 * * * *',
51+
'5 */2 * * *',
1752
{ protect: true },
1853
async () => {
19-
try {
20-
await generateMissionMakersList();
21-
} catch (err) {
22-
logger.error(`Error during fetching mission makers list. Trace: ${err.stack}`);
23-
}
54+
await runSiteRequestJob(
55+
'generateMissionMakersList',
56+
async () => {
57+
try {
58+
await generateMissionMakersList();
59+
} catch (err) {
60+
logger.error(`Error during fetching mission makers list. Trace: ${err.stack}`);
61+
}
62+
},
63+
);
2464
},
2565
);
2666

@@ -33,16 +73,21 @@ const generateMaceListJob = async () => {
3373
};
3474

3575
const replaysFetcherJob = Cron(
36-
'*/5 * * * *',
76+
'5 */2 * * *',
3777
{ protect: true },
3878
async () => {
39-
try {
40-
await startFetchingReplays();
41-
} catch (err) {
42-
logger.error(`Error during fetching replays list. Trace: ${err.stack}`);
43-
}
79+
await runSiteRequestJob(
80+
'startFetchingReplays',
81+
async () => {
82+
try {
83+
await startFetchingReplays();
84+
} catch (err) {
85+
logger.error(`Error during fetching replays list. Trace: ${err.stack}`);
86+
}
4487

45-
generateMaceListJob();
88+
generateMaceListJob();
89+
},
90+
);
4691
},
4792
);
4893

@@ -58,7 +103,7 @@ const waitReplaysFetchingToFinish = async (): Promise<void> => (
58103
);
59104

60105
Cron(
61-
'4 */1 * * *',
106+
'0 1-23/2 * * *',
62107
{ protect: true },
63108
async () => {
64109
if (replaysFetcherJob.isBusy()) {

0 commit comments

Comments
 (0)