Skip to content

Commit 2bc0119

Browse files
aboseclaude
andcommitted
fix: retry+validate live fetches and trim cumulative first-day in daily graph
Hardens the hourly build against the silent-reset bug: fetchWithRetries retries the live download_history / download_counts URLs three times, validates the JSON shape, and exits non-zero on final failure unless ALLOW_HISTORY_RESET=true is explicitly set. Daily-mode chart now always trims the first datapoint (which is the absolute cumulative count, not a diff), eliminating the giant first-day spike when history is short. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 510e0f3 commit 2bc0119

4 files changed

Lines changed: 72 additions & 46 deletions

File tree

build/downloadCounts.js

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const fs = require('fs');
22
const {RELEASE_ASSET_INSTALLER_SUFFIXES, RELEASE_ASSET_UPDATER_SUFFIXES} = require("./constants");
3+
const {fetchWithRetries} = require("./fetchWithRetries");
34

45
const ALL_INSTALLER_SUFFIXES = Object.values(RELEASE_ASSET_INSTALLER_SUFFIXES);
56
const ALL_UPDATER_SUFFIXES = Object.values(RELEASE_ASSET_UPDATER_SUFFIXES);
@@ -31,27 +32,17 @@ function computeTotalDownloads(releases) {
3132
return {totalInstallerDownloads, totalUpdaterDownloads};
3233
}
3334

34-
function isRunningInGitHubActions() {
35-
return process.env.GITHUB_ACTIONS === 'true';
35+
const DOWNLOAD_COUNTS_URL = "https://public-stats.phcode.io/generated/download_counts.json";
36+
37+
function validateCountsPayload(data) {
38+
if (!data || typeof data !== 'object') return 'response was not an object';
39+
if (typeof data.totalDownloads !== 'number') return 'missing totalDownloads';
40+
if (!data.timestamp) return 'missing timestamp';
41+
return null;
3642
}
3743

38-
const DOWNLOAD_COUNTS_URL = "https://public-stats.phcode.io/generated/download_counts.json";
3944
async function getCurrentDownloadData() {
40-
if(isRunningInGitHubActions()){
41-
// in github actions, there can be times when fetch fails in the automated hourly
42-
// workflows. In that case we should fail early, else it will reset the history
43-
// if we do try catch and return null. We dont want automated workflows resetting
44-
// history.
45-
const fetchedData = await fetch(DOWNLOAD_COUNTS_URL);
46-
return fetchedData.json();
47-
}
48-
try{
49-
const fetchedData = await fetch(DOWNLOAD_COUNTS_URL);
50-
return await fetchedData.json();
51-
} catch (e) {
52-
console.error("No previous data", e);
53-
}
54-
return null;
45+
return fetchWithRetries(DOWNLOAD_COUNTS_URL, validateCountsPayload, 'download_counts');
5546
}
5647

5748
async function updateDownloadStats(releases) {

build/downloadHistory.js

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const fs = require('fs');
22
const {SUFFIX_TO_NAME_MAP, RELEASE_ASSET_SUFFIXES_ALL} = require("./constants");
3+
const {fetchWithRetries} = require("./fetchWithRetries");
34

45
const ALL_SUFFIXES = Object.values(RELEASE_ASSET_SUFFIXES_ALL);
56
function getAssetType(assetName) {
@@ -125,29 +126,21 @@ function toStr(obj) {
125126
return JSON.stringify(obj, null, 2);
126127
}
127128

128-
function isRunningInGitHubActions() {
129-
return process.env.GITHUB_ACTIONS === 'true';
130-
}
131-
132129
const DOWNLOAD_HISTORY_URL = "https://public-stats.phcode.io/generated/download_history.json";
133-
async function getCurrentHistoryData() {
134-
if(isRunningInGitHubActions()){
135-
// in github actions, there can be times when fetch fails in the automated hourly
136-
// workflows. In that case we should fail early, else it will reset the history
137-
// if we do try catch and return null. We dont want automated workflows resetting
138-
// history.
139-
const fetchedData = await fetch(DOWNLOAD_HISTORY_URL);
140-
return fetchedData.json();
141-
}
142-
try{
143-
const fetchedData = await fetch(DOWNLOAD_HISTORY_URL);
144-
return await fetchedData.json();
145-
} catch (e) {
146-
console.error("No previous data", e);
130+
131+
function validateHistoryPayload(data) {
132+
if (!data || typeof data !== 'object') return 'response was not an object';
133+
if (!data.prodReleaseHistory || typeof data.prodReleaseHistory !== 'object') {
134+
return 'missing prodReleaseHistory';
147135
}
136+
if (Object.keys(data.prodReleaseHistory).length === 0) return 'prodReleaseHistory is empty';
148137
return null;
149138
}
150139

140+
async function getCurrentHistoryData() {
141+
return fetchWithRetries(DOWNLOAD_HISTORY_URL, validateHistoryPayload, 'download_history');
142+
}
143+
151144
async function updateDownloadHistory(releases) {
152145
const existingHistory = await getCurrentHistoryData();
153146
const existingProdReleaseHistory = (existingHistory && existingHistory.prodReleaseHistory) ?

build/fetchWithRetries.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
async function sleep(ms) {
2+
return new Promise(resolve => setTimeout(resolve, ms));
3+
}
4+
5+
// Fetches `url`, retries on transient failures, and validates the parsed JSON
6+
// against `validate(data)` (returns a string describing the problem, or null).
7+
// On final failure: exits the process unless ALLOW_HISTORY_RESET=true is set,
8+
// in which case it returns null so the caller can proceed with a blank slate.
9+
async function fetchWithRetries(url, validate, label, attempts = 3) {
10+
let lastError;
11+
for (let attempt = 1; attempt <= attempts; attempt++) {
12+
try {
13+
const response = await fetch(url);
14+
if (!response.ok) {
15+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
16+
}
17+
const data = await response.json();
18+
const problem = validate(data);
19+
if (problem) {
20+
throw new Error(`unexpected payload: ${problem}`);
21+
}
22+
return data;
23+
} catch (error) {
24+
lastError = error;
25+
console.warn(`[${label}] attempt ${attempt}/${attempts} failed: ${error.message}`);
26+
if (attempt < attempts) {
27+
await sleep(1000 * attempt);
28+
}
29+
}
30+
}
31+
if (process.env.ALLOW_HISTORY_RESET === 'true') {
32+
console.warn(`[${label}] ALLOW_HISTORY_RESET=true; proceeding with blank slate after ${attempts} failed attempts.`);
33+
return null;
34+
}
35+
console.error(`[${label}] fetch failed ${attempts} times. Last error: ${lastError && lastError.message}`);
36+
console.error(`[${label}] Refusing to overwrite live history with a blank slate.`);
37+
console.error(`[${label}] If you genuinely want to start fresh, re-run with ALLOW_HISTORY_RESET=true.`);
38+
process.exit(1);
39+
}
40+
41+
exports.fetchWithRetries = fetchWithRetries;

docs/download-graphs.html

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,8 @@ <h1>Individual Assets <span class="version-select-text"></span></h1>
249249
setSelectedOption(timePeriod);
250250

251251
function computeDailyDownloads(stats) {
252+
// The first entry is the cumulative count up to that day, not a daily
253+
// diff — callers must trim it from the rendered series.
252254
const dailyDownloads = [];
253255
if(stats.length >= 1){
254256
dailyDownloads.push(stats[0]);
@@ -271,16 +273,15 @@ <h1>Individual Assets <span class="version-select-text"></span></h1>
271273
computeDailyData = false;
272274
}
273275
let shouldTrimFirstElem = false;
274-
if(computeDailyData && (timeSeries.length > days || compareVersions.satisfies(version, "<=3.8.8"))){
275-
// if we have more stats, then we add one more day so that we can compute daily downloads
276-
// For eg, if we are computing 7 day daily downloads, and we have 8 days stats, we compute the 7th
277-
// day downloads by subtracting 7th dya amd 8th day downloads. but the array will have 8 entries,
278-
// the first being the absolute 8th day download count, which we should trim.
279-
280-
// for versions smaller than 3.8.8, we started creating history data mid way and we need to trim the
281-
// first element which is the aggregate of several months of downloads, not what we intend for daily
282-
// downloads
283-
days = days + 1;
276+
if(computeDailyData){
277+
// In daily mode the first datapoint is the absolute cumulative count
278+
// (and for versions <=3.8.8 it's months of aggregated downloads, plus
279+
// resets can leave the earliest entry as a huge cumulative baseline).
280+
// Always trim it. If we have more history than requested, extend the
281+
// slice by one day so the chart still spans the full window.
282+
if(timeSeries.length > days){
283+
days = days + 1;
284+
}
284285
shouldTrimFirstElem = true;
285286
}
286287
const trimIndex = Math.max(timeSeries.length - days, 0); // Calculate index to start slicing from

0 commit comments

Comments
 (0)