mirror of
https://github.com/LukeHagar/usage-statistics.git
synced 2025-12-06 04:21:55 +00:00
chore: update dependencies to include @octokit/plugin-retry and @octokit/plugin-throttling; refactor GitHubTracker to utilize these plugins for improved rate limiting and error handling
This commit is contained in:
18
bun.lock
18
bun.lock
@@ -5,6 +5,8 @@
|
||||
"name": "usage-statistics",
|
||||
"dependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
"@octokit/plugin-retry": "^7.0.0",
|
||||
"@octokit/plugin-throttling": "^7.0.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -41,9 +43,13 @@
|
||||
|
||||
"@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@16.0.0", "", { "dependencies": { "@octokit/types": "^14.1.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-kJVUQk6/dx/gRNLWUnAWKFs1kVPn5O5CYZyssyEoNYaFedqZxsfYs7DwI3d67hGz4qOwaJ1dpm07hOAD1BXx6g=="],
|
||||
|
||||
"@octokit/plugin-retry": ["@octokit/plugin-retry@7.2.1", "", { "dependencies": { "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-wUc3gv0D6vNHpGxSaR3FlqJpTXGWgqmk607N9L3LvPL4QjaxDgX/1nY2mGpT37Khn+nlIXdljczkRnNdTTV3/A=="],
|
||||
|
||||
"@octokit/plugin-throttling": ["@octokit/plugin-throttling@7.0.0", "", { "dependencies": { "@octokit/types": "^11.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": "^5.0.0" } }, "sha512-KL2k/d0uANc8XqP5S64YcNFCudR3F5AaKO39XWdUtlJIjT9Ni79ekWJ6Kj5xvAw87udkOMEPcVf9xEge2+ahew=="],
|
||||
|
||||
"@octokit/request": ["@octokit/request@10.0.3", "", { "dependencies": { "@octokit/endpoint": "^11.0.0", "@octokit/request-error": "^7.0.0", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA=="],
|
||||
|
||||
"@octokit/request-error": ["@octokit/request-error@7.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg=="],
|
||||
"@octokit/request-error": ["@octokit/request-error@6.1.8", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ=="],
|
||||
|
||||
"@octokit/rest": ["@octokit/rest@22.0.0", "", { "dependencies": { "@octokit/core": "^7.0.2", "@octokit/plugin-paginate-rest": "^13.0.1", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^16.0.0" } }, "sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA=="],
|
||||
|
||||
@@ -55,6 +61,8 @@
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
@@ -70,5 +78,13 @@
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
|
||||
|
||||
"@octokit/core/@octokit/request-error": ["@octokit/request-error@7.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg=="],
|
||||
|
||||
"@octokit/plugin-throttling/@octokit/types": ["@octokit/types@11.1.0", "", { "dependencies": { "@octokit/openapi-types": "^18.0.0" } }, "sha512-Fz0+7GyLm/bHt8fwEqgvRBWwIV1S6wRRyq+V6exRKLVWaKGsuy6H9QFYeBVDV7rK6fO3XwHgQOPxv+cLj2zpXQ=="],
|
||||
|
||||
"@octokit/request/@octokit/request-error": ["@octokit/request-error@7.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg=="],
|
||||
|
||||
"@octokit/plugin-throttling/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@18.1.1", "", {}, "sha512-VRaeH8nCDtF5aXWnjPuEMIYf1itK/s3JYyJcWFJT8X9pSNnBtriDf7wlEWsGuhPLl4QIH4xM8fqTXDwJ3Mu6sw=="],
|
||||
}
|
||||
}
|
||||
|
||||
98
dist/action.js
vendored
98
dist/action.js
vendored
File diff suppressed because one or more lines are too long
@@ -24,7 +24,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
"@octokit/rest": "22.0.0"
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@octokit/plugin-retry": "^7.0.0",
|
||||
"@octokit/plugin-throttling": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.0.0"
|
||||
|
||||
@@ -29,7 +29,14 @@ async function run() {
|
||||
// Set environment variables
|
||||
if (githubToken) {
|
||||
process.env.GITHUB_TOKEN = githubToken;
|
||||
core.info('✅ Using custom GitHub token for authentication');
|
||||
} else if (process.env.GITHUB_TOKEN) {
|
||||
// Use the default GitHub token if no custom token provided
|
||||
core.info('✅ Using default GitHub token from environment');
|
||||
} else {
|
||||
core.warning('⚠️ No GitHub token provided. Some API calls may be rate limited.');
|
||||
}
|
||||
|
||||
if (postmanApiKey) {
|
||||
process.env.POSTMAN_API_KEY = postmanApiKey;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Combines and analyzes statistics from all platform trackers
|
||||
*/
|
||||
|
||||
import type { BaseDownloadStats, DownloadStatsAggregator, TrackingConfig } from './types';
|
||||
import type { BaseDownloadStats, TrackingConfig } from './types';
|
||||
import NpmTracker from './trackers/npm';
|
||||
import GitHubTracker from './trackers/github';
|
||||
import PyPiTracker from './trackers/pypi';
|
||||
@@ -11,7 +11,6 @@ import HomebrewTracker from './trackers/homebrew';
|
||||
import PowerShellTracker from './trackers/powershell';
|
||||
import PostmanTracker from './trackers/postman';
|
||||
import GoTracker from './trackers/go';
|
||||
import { globalRateLimiter } from './utils/rate-limiter';
|
||||
|
||||
export interface AggregatedStats {
|
||||
totalDownloads: number;
|
||||
@@ -29,7 +28,7 @@ export interface AggregatedStats {
|
||||
}>;
|
||||
}
|
||||
|
||||
export class DownloadStatsAggregator implements DownloadStatsAggregator {
|
||||
export class DownloadStatsAggregator {
|
||||
private trackers: Map<string, any> = new Map();
|
||||
private config: TrackingConfig;
|
||||
|
||||
@@ -113,136 +112,122 @@ export class DownloadStatsAggregator implements DownloadStatsAggregator {
|
||||
async collectAllStats(): Promise<BaseDownloadStats[]> {
|
||||
const allStats: BaseDownloadStats[] = [];
|
||||
|
||||
// Collect NPM stats with rate limiting
|
||||
// Collect NPM stats
|
||||
if (this.config.npmPackages) {
|
||||
const npmOperations = this.config.npmPackages.map(packageName =>
|
||||
async () => {
|
||||
try {
|
||||
const npmTracker = this.trackers.get('npm');
|
||||
const stats = await npmTracker.getDownloadStats(packageName);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting NPM stats for ${packageName}:`, error);
|
||||
return [];
|
||||
}
|
||||
const npmPromises = this.config.npmPackages.map(async packageName => {
|
||||
try {
|
||||
const npmTracker = this.trackers.get('npm');
|
||||
const stats = await npmTracker.getDownloadStats(packageName);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting NPM stats for ${packageName}:`, error);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const npmResults = await globalRateLimiter.throttleRequests(npmOperations, 2, 2000);
|
||||
const npmResults = await Promise.all(npmPromises);
|
||||
npmResults.forEach(stats => allStats.push(...stats));
|
||||
}
|
||||
|
||||
// Collect GitHub stats with rate limiting
|
||||
// Collect GitHub stats (Octokit plugins handle rate limiting)
|
||||
if (this.config.githubRepos) {
|
||||
const githubOperations = this.config.githubRepos.map(repo =>
|
||||
async () => {
|
||||
try {
|
||||
const githubTracker = this.trackers.get('github');
|
||||
const stats = await githubTracker.getDownloadStats(repo);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting GitHub stats for ${repo}:`, error);
|
||||
return [];
|
||||
}
|
||||
const githubPromises = this.config.githubRepos.map(async repo => {
|
||||
try {
|
||||
const githubTracker = this.trackers.get('github');
|
||||
const stats = await githubTracker.getDownloadStats(repo);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting GitHub stats for ${repo}:`, error);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const githubResults = await globalRateLimiter.throttleRequests(githubOperations, 1, 3000);
|
||||
const githubResults = await Promise.all(githubPromises);
|
||||
githubResults.forEach(stats => allStats.push(...stats));
|
||||
}
|
||||
|
||||
// Collect PyPI stats with rate limiting
|
||||
// Collect PyPI stats
|
||||
if (this.config.pythonPackages) {
|
||||
const pypiOperations = this.config.pythonPackages.map(packageName =>
|
||||
async () => {
|
||||
try {
|
||||
const pypiTracker = this.trackers.get('pypi');
|
||||
const stats = await pypiTracker.getDownloadStats(packageName);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting PyPI stats for ${packageName}:`, error);
|
||||
return [];
|
||||
}
|
||||
const pypiPromises = this.config.pythonPackages.map(async packageName => {
|
||||
try {
|
||||
const pypiTracker = this.trackers.get('pypi');
|
||||
const stats = await pypiTracker.getDownloadStats(packageName);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting PyPI stats for ${packageName}:`, error);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const pypiResults = await globalRateLimiter.throttleRequests(pypiOperations, 2, 1500);
|
||||
const pypiResults = await Promise.all(pypiPromises);
|
||||
pypiResults.forEach(stats => allStats.push(...stats));
|
||||
}
|
||||
|
||||
// Collect Homebrew stats with rate limiting
|
||||
// Collect Homebrew stats
|
||||
if (this.config.homebrewPackages) {
|
||||
const homebrewOperations = this.config.homebrewPackages.map(packageName =>
|
||||
async () => {
|
||||
try {
|
||||
const homebrewTracker = this.trackers.get('homebrew');
|
||||
const stats = await homebrewTracker.getDownloadStats(packageName);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting Homebrew stats for ${packageName}:`, error);
|
||||
return [];
|
||||
}
|
||||
const homebrewPromises = this.config.homebrewPackages.map(async packageName => {
|
||||
try {
|
||||
const homebrewTracker = this.trackers.get('homebrew');
|
||||
const stats = await homebrewTracker.getDownloadStats(packageName);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting Homebrew stats for ${packageName}:`, error);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const homebrewResults = await globalRateLimiter.throttleRequests(homebrewOperations, 2, 2000);
|
||||
const homebrewResults = await Promise.all(homebrewPromises);
|
||||
homebrewResults.forEach(stats => allStats.push(...stats));
|
||||
}
|
||||
|
||||
// Collect PowerShell stats with rate limiting
|
||||
// Collect PowerShell stats
|
||||
if (this.config.powershellModules) {
|
||||
const powershellOperations = this.config.powershellModules.map(moduleName =>
|
||||
async () => {
|
||||
try {
|
||||
const powershellTracker = this.trackers.get('powershell');
|
||||
const stats = await powershellTracker.getDownloadStats(moduleName);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting PowerShell stats for ${moduleName}:`, error);
|
||||
return [];
|
||||
}
|
||||
const powershellPromises = this.config.powershellModules.map(async moduleName => {
|
||||
try {
|
||||
const powershellTracker = this.trackers.get('powershell');
|
||||
const stats = await powershellTracker.getDownloadStats(moduleName);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting PowerShell stats for ${moduleName}:`, error);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const powershellResults = await globalRateLimiter.throttleRequests(powershellOperations, 2, 2000);
|
||||
const powershellResults = await Promise.all(powershellPromises);
|
||||
powershellResults.forEach(stats => allStats.push(...stats));
|
||||
}
|
||||
|
||||
// Collect Postman stats with rate limiting
|
||||
// Collect Postman stats
|
||||
if (this.config.postmanCollections) {
|
||||
const postmanOperations = this.config.postmanCollections.map(collectionId =>
|
||||
async () => {
|
||||
try {
|
||||
const postmanTracker = this.trackers.get('postman');
|
||||
const stats = await postmanTracker.getDownloadStats(collectionId);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting Postman stats for ${collectionId}:`, error);
|
||||
return [];
|
||||
}
|
||||
const postmanPromises = this.config.postmanCollections.map(async collectionId => {
|
||||
try {
|
||||
const postmanTracker = this.trackers.get('postman');
|
||||
const stats = await postmanTracker.getDownloadStats(collectionId);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting Postman stats for ${collectionId}:`, error);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const postmanResults = await globalRateLimiter.throttleRequests(postmanOperations, 2, 2000);
|
||||
const postmanResults = await Promise.all(postmanPromises);
|
||||
postmanResults.forEach(stats => allStats.push(...stats));
|
||||
}
|
||||
|
||||
// Collect Go stats with rate limiting
|
||||
// Collect Go stats
|
||||
if (this.config.goModules) {
|
||||
const goOperations = this.config.goModules.map(moduleName =>
|
||||
async () => {
|
||||
try {
|
||||
const goTracker = this.trackers.get('go');
|
||||
const stats = await goTracker.getDownloadStats(moduleName);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting Go stats for ${moduleName}:`, error);
|
||||
return [];
|
||||
}
|
||||
const goPromises = this.config.goModules.map(async moduleName => {
|
||||
try {
|
||||
const goTracker = this.trackers.get('go');
|
||||
const stats = await goTracker.getDownloadStats(moduleName);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting Go stats for ${moduleName}:`, error);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const goResults = await globalRateLimiter.throttleRequests(goOperations, 2, 2000);
|
||||
const goResults = await Promise.all(goPromises);
|
||||
goResults.forEach(stats => allStats.push(...stats));
|
||||
}
|
||||
|
||||
@@ -263,19 +248,17 @@ export class DownloadStatsAggregator implements DownloadStatsAggregator {
|
||||
const allStats: BaseDownloadStats[] = [];
|
||||
const packages = this.getPackagesForPlatform(platform);
|
||||
|
||||
const operations = packages.map(packageName =>
|
||||
async () => {
|
||||
try {
|
||||
const stats = await tracker.getDownloadStats(packageName);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting ${platform} stats for ${packageName}:`, error);
|
||||
return [];
|
||||
}
|
||||
const promises = packages.map(async packageName => {
|
||||
try {
|
||||
const stats = await tracker.getDownloadStats(packageName);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error(`Error collecting ${platform} stats for ${packageName}:`, error);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const results = await globalRateLimiter.throttleRequests(operations, 2, 2000);
|
||||
const results = await Promise.all(promises);
|
||||
results.forEach(stats => allStats.push(...stats));
|
||||
|
||||
return allStats;
|
||||
|
||||
52
src/github-tracker.test.ts
Normal file
52
src/github-tracker.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import GitHubTracker from "./trackers/github";
|
||||
|
||||
describe("GitHubTracker", () => {
|
||||
let tracker: GitHubTracker;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use a test token or no token for testing
|
||||
tracker = new GitHubTracker(process.env.GITHUB_TOKEN || undefined);
|
||||
});
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
it("should handle rate limiting gracefully", async () => {
|
||||
// Test with a popular repository that might hit rate limits
|
||||
const stats = await tracker.getDownloadStats('microsoft/vscode');
|
||||
|
||||
// Should return an array (even if empty due to rate limiting)
|
||||
expect(Array.isArray(stats)).toBe(true);
|
||||
}, 15000); // 15 second timeout for rate limit handling
|
||||
|
||||
it("should get package info", async () => {
|
||||
try {
|
||||
const info = await tracker.getPackageInfo('microsoft/vscode');
|
||||
expect(info).toBeDefined();
|
||||
expect(info.name).toBe('vscode');
|
||||
expect(info.full_name).toBe('microsoft/vscode');
|
||||
} catch (error) {
|
||||
// If rate limited, that's expected behavior
|
||||
console.log('Rate limited during test (expected):', error);
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
it("should get latest version", async () => {
|
||||
try {
|
||||
const version = await tracker.getLatestVersion('microsoft/vscode');
|
||||
// Should return a version string or null
|
||||
expect(version === null || typeof version === 'string').toBe(true);
|
||||
} catch (error) {
|
||||
// If rate limited, that's expected behavior
|
||||
console.log('Rate limited during test (expected):', error);
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe("Configuration", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tracker.name).toBe('github');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,15 +7,15 @@ describe("UsageStatisticsManager", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
const config: TrackingConfig = {
|
||||
enableLogging: true,
|
||||
enableLogging: false, // Disable logging for tests
|
||||
updateInterval: 60 * 60 * 1000,
|
||||
npmPackages: ['lodash', 'axios'],
|
||||
githubRepos: ['microsoft/vscode', 'facebook/react'],
|
||||
pythonPackages: ['requests', 'numpy'],
|
||||
homebrewPackages: ['git', 'node'],
|
||||
npmPackages: ['lodash'], // Reduce to single package for faster tests
|
||||
githubRepos: ['microsoft/vscode'], // Reduce to single repo
|
||||
pythonPackages: ['requests'], // Reduce to single package
|
||||
homebrewPackages: ['git'], // Reduce to single package
|
||||
powershellModules: ['PowerShellGet'],
|
||||
postmanCollections: [],
|
||||
goModules: ['github.com/gin-gonic/gin', 'github.com/go-chi/chi']
|
||||
goModules: ['github.com/go-chi/chi'] // Reduce to single module
|
||||
};
|
||||
manager = new UsageStatisticsManager(config);
|
||||
});
|
||||
@@ -31,7 +31,7 @@ describe("UsageStatisticsManager", () => {
|
||||
expect(Array.isArray(report.topPackages)).toBe(true);
|
||||
expect(report.topPackages.length).toBeGreaterThan(0);
|
||||
expect(typeof report.platformBreakdown).toBe('object');
|
||||
}, 30000); // 30 second timeout
|
||||
}, 10000); // 10 second timeout
|
||||
});
|
||||
|
||||
describe("getPlatformReport", () => {
|
||||
@@ -54,7 +54,7 @@ describe("UsageStatisticsManager", () => {
|
||||
// Should be valid JSON
|
||||
const parsed = JSON.parse(jsonReport);
|
||||
expect(parsed).toBeDefined();
|
||||
}, 30000); // 30 second timeout
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
it("should export CSV report", async () => {
|
||||
const csvReport = await manager.exportReport('csv');
|
||||
@@ -62,7 +62,7 @@ describe("UsageStatisticsManager", () => {
|
||||
expect(csvReport).toBeDefined();
|
||||
expect(typeof csvReport).toBe('string');
|
||||
expect(csvReport.includes('Platform,Package,Downloads')).toBe(true);
|
||||
}, 30000); // 30 second timeout
|
||||
}, 10000); // 10 second timeout
|
||||
});
|
||||
|
||||
describe("getLastUpdateTime", () => {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import type { BaseDownloadStats, PlatformTracker } from '../types';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { retry } from '@octokit/plugin-retry';
|
||||
import { throttling } from '@octokit/plugin-throttling';
|
||||
|
||||
export interface GitHubDownloadStats extends BaseDownloadStats {
|
||||
platform: 'github';
|
||||
@@ -57,30 +59,35 @@ export class GitHubTracker implements PlatformTracker {
|
||||
}
|
||||
|
||||
private async initializeOctokit() {
|
||||
if (typeof window === 'undefined') {
|
||||
// Server-side: use Octokit
|
||||
this.octokit = new Octokit({
|
||||
auth: this.token,
|
||||
userAgent: 'usage-statistics-tracker',
|
||||
timeZone: 'UTC',
|
||||
baseUrl: 'https://api.github.com',
|
||||
log: {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: console.warn,
|
||||
error: console.error
|
||||
// Create Octokit with retry and throttling plugins
|
||||
const MyOctokit = Octokit.plugin(retry, throttling);
|
||||
|
||||
this.octokit = new MyOctokit({
|
||||
auth: this.token,
|
||||
userAgent: 'usage-statistics-tracker',
|
||||
timeZone: 'UTC',
|
||||
baseUrl: 'https://api.github.com',
|
||||
log: {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: console.warn,
|
||||
error: console.error
|
||||
},
|
||||
throttle: {
|
||||
onRateLimit: (retryAfter: number, options: any) => {
|
||||
console.warn(`Rate limit hit for ${options.request.url}, retrying after ${retryAfter} seconds`);
|
||||
return true; // Retry after the specified time
|
||||
},
|
||||
request: {
|
||||
timeout: 10000,
|
||||
retries: 3,
|
||||
retryAfterBaseValue: 1,
|
||||
retryAfterMaxValue: 60
|
||||
onSecondaryRateLimit: (retryAfter: number, options: any) => {
|
||||
console.warn(`Secondary rate limit hit for ${options.request.url}, retrying after ${retryAfter} seconds`);
|
||||
return true; // Retry after the specified time
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Client-side: use fetch API
|
||||
this.octokit = null;
|
||||
}
|
||||
},
|
||||
retry: {
|
||||
doNotRetry: [400, 401, 403, 404, 422], // Don't retry on these status codes
|
||||
enabled: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getDownloadStats(repository: string, options?: {
|
||||
@@ -154,91 +161,41 @@ export class GitHubTracker implements PlatformTracker {
|
||||
async getPackageInfo(repository: string): Promise<GitHubRepositoryInfo> {
|
||||
const [owner, repo] = repository.split('/');
|
||||
|
||||
if (this.octokit) {
|
||||
// Use Octokit if available
|
||||
try {
|
||||
const response = await this.octokit.repos.get({
|
||||
owner,
|
||||
repo
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.status === 403 && error.message.includes('abuse detection')) {
|
||||
console.warn(`Rate limit hit for ${repository}, waiting 60 seconds...`);
|
||||
await this.sleep(60000);
|
||||
return this.getPackageInfo(repository); // Retry once
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// Fallback to fetch API
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: this.token ? {
|
||||
'Authorization': `token ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
} : {
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
if (!this.octokit) {
|
||||
throw new Error('Octokit not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.octokit.repos.get({
|
||||
owner,
|
||||
repo
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
console.warn(`Rate limit hit for ${repository}, waiting 60 seconds...`);
|
||||
await this.sleep(60000);
|
||||
return this.getPackageInfo(repository); // Retry once
|
||||
}
|
||||
throw new Error(`Failed to fetch repository info for ${repository}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`Error fetching repository info for ${repository}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async getReleases(owner: string, repo: string): Promise<GitHubReleaseInfo[]> {
|
||||
if (this.octokit) {
|
||||
// Use Octokit if available
|
||||
try {
|
||||
const response = await this.octokit.repos.listReleases({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.status === 403 && error.message.includes('abuse detection')) {
|
||||
console.warn(`Rate limit hit for ${owner}/${repo}, waiting 60 seconds...`);
|
||||
await this.sleep(60000);
|
||||
return this.getReleases(owner, repo); // Retry once
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// Fallback to fetch API
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases?per_page=100`, {
|
||||
headers: this.token ? {
|
||||
'Authorization': `token ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
} : {
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
if (!this.octokit) {
|
||||
throw new Error('Octokit not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.octokit.repos.listReleases({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
console.warn(`Rate limit hit for ${owner}/${repo}, waiting 60 seconds...`);
|
||||
await this.sleep(60000);
|
||||
return this.getReleases(owner, repo); // Retry once
|
||||
}
|
||||
throw new Error(`Failed to fetch releases for ${owner}/${repo}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`Error fetching releases for ${owner}/${repo}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default GitHubTracker;
|
||||
@@ -17,14 +17,6 @@ export interface PlatformTracker {
|
||||
getPackageInfo(packageName: string): Promise<any>;
|
||||
}
|
||||
|
||||
export interface DownloadStatsAggregator {
|
||||
aggregateStats(stats: BaseDownloadStats[]): {
|
||||
totalDownloads: number;
|
||||
uniquePackages: number;
|
||||
platforms: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface TrackingConfig {
|
||||
npmPackages?: string[];
|
||||
goModules?: string[];
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Rate Limiting Utility
|
||||
* Handles API rate limits with exponential backoff and proper error handling
|
||||
*/
|
||||
|
||||
export interface RateLimitConfig {
|
||||
maxRetries: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
backoffMultiplier: number;
|
||||
}
|
||||
|
||||
export class RateLimiter {
|
||||
private config: RateLimitConfig;
|
||||
private requestCounts: Map<string, { count: number; resetTime: number }> = new Map();
|
||||
|
||||
constructor(config: Partial<RateLimitConfig> = {}) {
|
||||
this.config = {
|
||||
maxRetries: config.maxRetries || 3,
|
||||
baseDelay: config.baseDelay || 1000,
|
||||
maxDelay: config.maxDelay || 60000,
|
||||
backoffMultiplier: config.backoffMultiplier || 2
|
||||
};
|
||||
}
|
||||
|
||||
async executeWithRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
operationName: string,
|
||||
retryCount = 0
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error: any) {
|
||||
if (this.shouldRetry(error) && retryCount < this.config.maxRetries) {
|
||||
const delay = this.calculateDelay(retryCount);
|
||||
console.warn(`Rate limit hit for ${operationName}, retrying in ${delay}ms (attempt ${retryCount + 1}/${this.config.maxRetries})`);
|
||||
|
||||
await this.sleep(delay);
|
||||
return this.executeWithRetry(operation, operationName, retryCount + 1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRetry(error: any): boolean {
|
||||
// Check for rate limiting errors
|
||||
if (error.status === 403 || error.status === 429) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for abuse detection
|
||||
if (error.message && error.message.includes('abuse detection')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for rate limit headers
|
||||
if (error.headers && (error.headers['x-ratelimit-remaining'] === '0' || error.headers['retry-after'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private calculateDelay(retryCount: number): number {
|
||||
const delay = this.config.baseDelay * Math.pow(this.config.backoffMultiplier, retryCount);
|
||||
return Math.min(delay, this.config.maxDelay);
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async throttleRequests<T>(
|
||||
operations: Array<() => Promise<T>>,
|
||||
maxConcurrent = 3,
|
||||
delayBetweenRequests = 1000
|
||||
): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
|
||||
for (let i = 0; i < operations.length; i += maxConcurrent) {
|
||||
const batch = operations.slice(i, i + maxConcurrent);
|
||||
const batchPromises = batch.map(async (operation, index) => {
|
||||
// Add delay between requests in the same batch
|
||||
if (index > 0) {
|
||||
await this.sleep(delayBetweenRequests);
|
||||
}
|
||||
return this.executeWithRetry(operation, `batch-${i}-${index}`);
|
||||
});
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
results.push(...batchResults);
|
||||
|
||||
// Add delay between batches
|
||||
if (i + maxConcurrent < operations.length) {
|
||||
await this.sleep(delayBetweenRequests * 2);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Track rate limits for different APIs
|
||||
trackRequest(apiName: string, resetTime?: number): void {
|
||||
const now = Date.now();
|
||||
const current = this.requestCounts.get(apiName) || { count: 0, resetTime: now + 3600000 }; // Default 1 hour
|
||||
|
||||
current.count++;
|
||||
|
||||
if (resetTime) {
|
||||
current.resetTime = resetTime * 1000; // Convert to milliseconds
|
||||
}
|
||||
|
||||
this.requestCounts.set(apiName, current);
|
||||
}
|
||||
|
||||
isRateLimited(apiName: string): boolean {
|
||||
const current = this.requestCounts.get(apiName);
|
||||
if (!current) return false;
|
||||
|
||||
const now = Date.now();
|
||||
if (now > current.resetTime) {
|
||||
this.requestCounts.delete(apiName);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Conservative estimate - assume we're rate limited if we've made many requests recently
|
||||
return current.count > 50;
|
||||
}
|
||||
|
||||
getRemainingTime(apiName: string): number {
|
||||
const current = this.requestCounts.get(apiName);
|
||||
if (!current) return 0;
|
||||
|
||||
const now = Date.now();
|
||||
return Math.max(0, current.resetTime - now);
|
||||
}
|
||||
}
|
||||
|
||||
// Global rate limiter instance
|
||||
export const globalRateLimiter = new RateLimiter();
|
||||
Reference in New Issue
Block a user