Skip to content

Commit e84d73c

Browse files
authored
Merge pull request #76 from LordDashMe/fix/node-24-compat-and-504-timeout
Fix node 24 compatibility and 504 timeout
2 parents d0b0db2 + ee51b13 commit e84d73c

11 files changed

Lines changed: 288 additions & 43 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
node_modules/
33
jest-cache/
44
jest-coverage/
5+
package-lock.json

api/index.js

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,40 @@
99

1010
const { StargazersController } = require('../src/StargazersController');
1111
const { ContributionController } = require('../src/ContributionController');
12+
const { ErrorTemplate } = require('../src/ErrorTemplate');
1213

1314
module.exports = async (req, res) => {
1415

1516
const { username } = req.query;
16-
17+
1718
res.setHeader('Cache-Control', 'public, max-age=1800');
1819
res.setHeader('Content-Type', 'image/svg+xml');
1920

20-
const isStargazer = await StargazersController(username);
21-
const template = await ContributionController(username, isStargazer);
21+
try {
22+
23+
if (!username) {
24+
throw new Error('The "username" query parameter is required.');
25+
}
26+
27+
// The stargazer flag is only needed at the point the card is rendered, so
28+
// the lookup is started here without awaiting. That lets it run alongside
29+
// the contribution stats request instead of running before it, which
30+
// halves the round trips sitting in front of the response.
31+
const isStargazer = StargazersController(username);
32+
const template = await ContributionController(username, isStargazer);
33+
34+
res.send(template);
35+
36+
} catch (error) {
37+
38+
console.error(error);
2239

23-
res.send(template);
40+
// A response has to be sent on every path. An unhandled rejection here
41+
// means the request is never answered and the platform eventually kills
42+
// it with a timeout, which is reported as a 504 rather than the real
43+
// error. The failure is not cacheable, so the header set above is
44+
// replaced before the fallback card is returned.
45+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
46+
res.send(ErrorTemplate(error.message));
47+
}
2448
};

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ services:
44
container_name: 'github-contribution-stats-app'
55
tty: true
66
stdin_open: true
7-
image: 'node:20-alpine'
7+
image: 'node:24-alpine'
88
volumes:
99
- '.:/app/'
1010
networks:

package.json

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "github-contribution-stats",
3-
"version": "1.1.1",
3+
"version": "1.2.0",
44
"description": "Dynamically generated Github Contribution Stats.",
55
"main": "index.js",
66
"scripts": {
@@ -17,13 +17,16 @@
1717
"url": "https://github.com/LordDashMe/github-contribution-stats/issues"
1818
},
1919
"homepage": "https://github.com/LordDashMe/github-contribution-stats#readme",
20+
"engines": {
21+
"node": "24.x"
22+
},
2023
"devDependencies": {
21-
"axios-mock-adapter": "^1.22.0",
22-
"jest": "^29.7.0",
23-
"jest-environment-jsdom": "^29.7.0"
24+
"axios-mock-adapter": "^2.1.0",
25+
"jest": "^30.4.2",
26+
"jest-environment-jsdom": "^30.4.1"
2427
},
2528
"dependencies": {
26-
"axios": "^1.6.8",
27-
"dotenv": "^16.4.5"
29+
"axios": "^1.19.0",
30+
"dotenv": "^17.4.2"
2831
}
2932
}

src/ContributionController.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,16 @@ const ContributionController = async (username, isStargazer) => {
1616

1717
const stats = await FetchStats(username);
1818

19+
// A failed request resolves to an empty object, reaching into it blindly
20+
// throws a TypeError that reads nothing like the actual cause. The common
21+
// reasons are an expired access token, an exhausted rate limit or a
22+
// username that does not exist.
23+
if (!stats || !stats.data || !stats.data.user) {
24+
throw new Error(`Unable to fetch the Github stats for the username "${username}".`);
25+
}
26+
1927
const computedContribution = ComputeContributions(stats.data.user.contributionsCollection);
20-
28+
2129
ContributionRatings.newInstance();
2230
ContributionRatings.setThisYearCommits(computedContribution.thisYear);
2331
ContributionRatings.setThisMonthCommits(computedContribution.thisMonth);
@@ -27,8 +35,12 @@ const ContributionController = async (username, isStargazer) => {
2735
ContributionRatings.setCodeReviews(computedContribution.codeReviews);
2836
ContributionRatings.calculate();
2937

38+
// Accepts either a plain boolean or the still pending lookup started by the
39+
// caller, awaiting a non promise value resolves to the value itself.
40+
const stargazer = await isStargazer;
41+
3042
return CardTemplates(
31-
isStargazer,
43+
stargazer,
3244
ContributionRatings.getLetterSign(),
3345
ContributionRatings.getTranslation(),
3446
ContributionRatings.getColor(),

src/ErrorTemplate.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* This file is part of the Github Contribution Stats.
3+
*
4+
* (c) Joshua Clifford Reyes <reyesjoshuaclifford@gmail.com>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
/**
11+
* The Error Template Function.
12+
*
13+
* The endpoint is consumed as an image, so a failure still needs to render as
14+
* a valid SVG. Returning nothing leaves the request hanging until the platform
15+
* timeout is reached, which surfaces to the user as a broken image.
16+
*
17+
* @author Joshua Clifford Reyes <reyesjoshuaclifford@gmail.com>
18+
*
19+
* @param {String} message The reason why the card cannot be generated.
20+
*
21+
* @return {String}
22+
*/
23+
const ErrorTemplate = (message) => {
24+
25+
// The message is injected into markup, the five XML predefined
26+
// entities are escaped so a malformed value cannot break the document.
27+
const safeMessage = String(message)
28+
.replace(/&/g, '&amp;')
29+
.replace(/</g, '&lt;')
30+
.replace(/>/g, '&gt;')
31+
.replace(/"/g, '&quot;')
32+
.replace(/'/g, '&apos;');
33+
34+
return `
35+
<svg width="328" height="120" viewBox="0 0 328 120" xmlns="http://www.w3.org/2000/svg">
36+
<style>
37+
.error-title {
38+
font-family: "Segoe UI", Roboto, Ubuntu, "Helvetica Neue", sans-serif;
39+
font-size: 16px;
40+
font-weight: 700;
41+
fill: #bf3838;
42+
}
43+
.error-message {
44+
font-family: "Segoe UI", Roboto, Ubuntu, "Helvetica Neue", sans-serif;
45+
font-size: 11px;
46+
fill: #666;
47+
}
48+
</style>
49+
<rect x="0.5" y="0.5" rx="6" height="119" stroke="#e4e2e2" width="327" fill="#fffefe" stroke-opacity="1" />
50+
<text x="25" y="40" class="error-title">Github Contribution Stats</text>
51+
<text x="25" y="65" class="error-message">Unable to generate the card right now.</text>
52+
<text x="25" y="85" class="error-message">${safeMessage}</text>
53+
</svg>
54+
`;
55+
};
56+
57+
module.exports = {
58+
ErrorTemplate: ErrorTemplate
59+
};

src/StargazersController.js

Lines changed: 62 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,57 +10,89 @@
1010
const { StargazersChecker } = require('./StargazersChecker');
1111
const { FetchRepositoryStargazers } = require('./FetchRepositoryStargazers');
1212

13+
// The stargazers list is walked one page at a time and a username that never
14+
// starred the repository only settles on the very last page. Both limits below
15+
// stop that walk from growing with the repository and taking the whole request
16+
// down with it, the flag only controls a footer on the card so falling back to
17+
// "not a stargazer" is far better than failing to answer at all.
18+
const MAX_PAGES = 10;
19+
const DEADLINE_IN_MILLISECONDS = 4000;
20+
1321
const StargazersController = async (username, items) => {
1422

1523
// The default value or the max allowed items from the github
1624
// to be fetch for each page.
1725
if (typeof items === 'undefined') {
18-
items = '100';
26+
items = '100';
27+
}
28+
29+
if (!username) {
30+
return false;
1931
}
2032

33+
const deadline = Date.now() + DEADLINE_IN_MILLISECONDS;
34+
2135
let isStargazer = false;
2236
let nextPage = true;
2337
let stargazerCheckingEnd = false;
2438
let cursor = '';
39+
let pages = 0;
2540

26-
do {
41+
try {
2742

28-
let repositoryStargazers = {};
43+
do {
2944

30-
if (cursor) {
31-
repositoryStargazers = await FetchRepositoryStargazers(items, cursor);
32-
} else {
33-
repositoryStargazers = await FetchRepositoryStargazers(items);
34-
}
45+
let repositoryStargazers = {};
3546

36-
if (typeof repositoryStargazers.data !== 'undefined' && typeof repositoryStargazers.data.repository.stargazers !== 'undefined') {
37-
38-
isStargazer = StargazersChecker(username, repositoryStargazers.data.repository.stargazers);
39-
40-
// Is a legit user who starred the repository :-)
41-
if (isStargazer) {
42-
stargazerCheckingEnd = true;
43-
break;
47+
if (cursor) {
48+
repositoryStargazers = await FetchRepositoryStargazers(items, cursor);
49+
} else {
50+
repositoryStargazers = await FetchRepositoryStargazers(items);
4451
}
45-
46-
nextPage = repositoryStargazers.data.repository.stargazers.pageInfo.hasNextPage;
4752

48-
// At this point we are now sure that the user
49-
// is not a stargazer of the repository.
50-
if (!nextPage) {
51-
break;
53+
pages++;
54+
55+
if (typeof repositoryStargazers.data !== 'undefined' && typeof repositoryStargazers.data.repository.stargazers !== 'undefined') {
56+
57+
isStargazer = StargazersChecker(username, repositoryStargazers.data.repository.stargazers);
58+
59+
// Is a legit user who starred the repository :-)
60+
if (isStargazer) {
61+
stargazerCheckingEnd = true;
62+
break;
63+
}
64+
65+
nextPage = repositoryStargazers.data.repository.stargazers.pageInfo.hasNextPage;
66+
67+
// At this point we are now sure that the user
68+
// is not a stargazer of the repository.
69+
if (!nextPage) {
70+
break;
71+
}
72+
73+
// Give up the search once either budget is spent, the remaining pages
74+
// are not worth the risk of timing out the request.
75+
if (pages >= MAX_PAGES || Date.now() >= deadline) {
76+
break;
77+
}
78+
79+
// Continue the searching if the user is a stargazer for the repository.
80+
// Maybe we can find it on the next page.
81+
const edges = repositoryStargazers.data.repository.stargazers.edges;
82+
cursor = edges[edges.length - 1].cursor;
83+
continue;
5284
}
5385

54-
// Continue the searching if the user is a stargazer for the repository.
55-
// Maybe we can find it on the next page.
56-
const edges = repositoryStargazers.data.repository.stargazers.edges;
57-
cursor = edges[edges.length - 1].cursor;
58-
continue;
59-
}
86+
stargazerCheckingEnd = true;
87+
88+
} while (!stargazerCheckingEnd && nextPage);
6089

61-
stargazerCheckingEnd = true;
90+
} catch (error) {
6291

63-
} while (!stargazerCheckingEnd && nextPage);
92+
// Never let a cosmetic lookup take down the card.
93+
console.error(error);
94+
return false;
95+
}
6496

6597
return isStargazer;
6698

tests/src/ContributionController.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,27 @@ describe('Tests for Contribution Controller.', () => {
131131

132132
});
133133

134+
it('should throw a readable error when the stats request fails.', async () => {
135+
136+
// An expired token or an exhausted rate limit resolves to an empty object,
137+
// the controller has to name the cause instead of dereferencing into it.
138+
mockAxios.onPost('https://api.github.com/graphql').reply(401, {});
139+
140+
await expect(ContributionController('LordDashMe')).rejects.toThrow(
141+
'Unable to fetch the Github stats for the username "LordDashMe".'
142+
);
143+
144+
});
145+
146+
it('should accept a pending stargazer lookup as well as a plain boolean.', async () => {
147+
148+
mockAxios.onPost('https://api.github.com/graphql').reply(200, mockGithubGrapQLResponse);
149+
150+
const fromPromise = await ContributionController('LordDashMe', Promise.resolve(true));
151+
const fromBoolean = await ContributionController('LordDashMe', true);
152+
153+
expect(fromPromise).toBe(fromBoolean);
154+
155+
});
156+
134157
});

tests/src/ErrorTemplate.test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const { ErrorTemplate } = require('../../src/ErrorTemplate');
2+
3+
describe('Tests for Error Template.', () => {
4+
5+
it('should render a valid svg carrying the given message.', () => {
6+
7+
const template = ErrorTemplate('Something went wrong.');
8+
9+
expect(template).toContain('<svg');
10+
expect(template).toContain('</svg>');
11+
expect(template).toContain('Something went wrong.');
12+
13+
});
14+
15+
it('should escape the xml predefined entities in the message.', () => {
16+
17+
const template = ErrorTemplate(`<script>&"'`);
18+
19+
expect(template).toContain('&lt;script&gt;&amp;&quot;&apos;');
20+
expect(template).not.toContain('<script>');
21+
22+
});
23+
24+
});

0 commit comments

Comments
 (0)