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:
Luke Hagar
2025-07-30 11:17:44 -05:00
parent b46dc85001
commit 5aa7ed039e
10 changed files with 285 additions and 406 deletions

View File

@@ -5,6 +5,8 @@
"name": "usage-statistics", "name": "usage-statistics",
"dependencies": { "dependencies": {
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@octokit/plugin-retry": "^7.0.0",
"@octokit/plugin-throttling": "^7.0.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
}, },
"devDependencies": { "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-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": ["@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=="], "@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=="], "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=="], "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=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -70,5 +78,13 @@
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], "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

File diff suppressed because one or more lines are too long

View File

@@ -24,7 +24,9 @@
}, },
"dependencies": { "dependencies": {
"@actions/core": "1.11.1", "@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": { "engines": {
"bun": ">=1.0.0" "bun": ">=1.0.0"

View File

@@ -29,7 +29,14 @@ async function run() {
// Set environment variables // Set environment variables
if (githubToken) { if (githubToken) {
process.env.GITHUB_TOKEN = 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) { if (postmanApiKey) {
process.env.POSTMAN_API_KEY = postmanApiKey; process.env.POSTMAN_API_KEY = postmanApiKey;
} }

View File

@@ -3,7 +3,7 @@
* Combines and analyzes statistics from all platform trackers * 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 NpmTracker from './trackers/npm';
import GitHubTracker from './trackers/github'; import GitHubTracker from './trackers/github';
import PyPiTracker from './trackers/pypi'; import PyPiTracker from './trackers/pypi';
@@ -11,7 +11,6 @@ import HomebrewTracker from './trackers/homebrew';
import PowerShellTracker from './trackers/powershell'; import PowerShellTracker from './trackers/powershell';
import PostmanTracker from './trackers/postman'; import PostmanTracker from './trackers/postman';
import GoTracker from './trackers/go'; import GoTracker from './trackers/go';
import { globalRateLimiter } from './utils/rate-limiter';
export interface AggregatedStats { export interface AggregatedStats {
totalDownloads: number; 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 trackers: Map<string, any> = new Map();
private config: TrackingConfig; private config: TrackingConfig;
@@ -113,10 +112,9 @@ export class DownloadStatsAggregator implements DownloadStatsAggregator {
async collectAllStats(): Promise<BaseDownloadStats[]> { async collectAllStats(): Promise<BaseDownloadStats[]> {
const allStats: BaseDownloadStats[] = []; const allStats: BaseDownloadStats[] = [];
// Collect NPM stats with rate limiting // Collect NPM stats
if (this.config.npmPackages) { if (this.config.npmPackages) {
const npmOperations = this.config.npmPackages.map(packageName => const npmPromises = this.config.npmPackages.map(async packageName => {
async () => {
try { try {
const npmTracker = this.trackers.get('npm'); const npmTracker = this.trackers.get('npm');
const stats = await npmTracker.getDownloadStats(packageName); const stats = await npmTracker.getDownloadStats(packageName);
@@ -125,17 +123,15 @@ export class DownloadStatsAggregator implements DownloadStatsAggregator {
console.error(`Error collecting NPM stats for ${packageName}:`, error); console.error(`Error collecting NPM stats for ${packageName}:`, error);
return []; return [];
} }
} });
);
const npmResults = await globalRateLimiter.throttleRequests(npmOperations, 2, 2000); const npmResults = await Promise.all(npmPromises);
npmResults.forEach(stats => allStats.push(...stats)); npmResults.forEach(stats => allStats.push(...stats));
} }
// Collect GitHub stats with rate limiting // Collect GitHub stats (Octokit plugins handle rate limiting)
if (this.config.githubRepos) { if (this.config.githubRepos) {
const githubOperations = this.config.githubRepos.map(repo => const githubPromises = this.config.githubRepos.map(async repo => {
async () => {
try { try {
const githubTracker = this.trackers.get('github'); const githubTracker = this.trackers.get('github');
const stats = await githubTracker.getDownloadStats(repo); const stats = await githubTracker.getDownloadStats(repo);
@@ -144,17 +140,15 @@ export class DownloadStatsAggregator implements DownloadStatsAggregator {
console.error(`Error collecting GitHub stats for ${repo}:`, error); console.error(`Error collecting GitHub stats for ${repo}:`, error);
return []; return [];
} }
} });
);
const githubResults = await globalRateLimiter.throttleRequests(githubOperations, 1, 3000); const githubResults = await Promise.all(githubPromises);
githubResults.forEach(stats => allStats.push(...stats)); githubResults.forEach(stats => allStats.push(...stats));
} }
// Collect PyPI stats with rate limiting // Collect PyPI stats
if (this.config.pythonPackages) { if (this.config.pythonPackages) {
const pypiOperations = this.config.pythonPackages.map(packageName => const pypiPromises = this.config.pythonPackages.map(async packageName => {
async () => {
try { try {
const pypiTracker = this.trackers.get('pypi'); const pypiTracker = this.trackers.get('pypi');
const stats = await pypiTracker.getDownloadStats(packageName); const stats = await pypiTracker.getDownloadStats(packageName);
@@ -163,17 +157,15 @@ export class DownloadStatsAggregator implements DownloadStatsAggregator {
console.error(`Error collecting PyPI stats for ${packageName}:`, error); console.error(`Error collecting PyPI stats for ${packageName}:`, error);
return []; return [];
} }
} });
);
const pypiResults = await globalRateLimiter.throttleRequests(pypiOperations, 2, 1500); const pypiResults = await Promise.all(pypiPromises);
pypiResults.forEach(stats => allStats.push(...stats)); pypiResults.forEach(stats => allStats.push(...stats));
} }
// Collect Homebrew stats with rate limiting // Collect Homebrew stats
if (this.config.homebrewPackages) { if (this.config.homebrewPackages) {
const homebrewOperations = this.config.homebrewPackages.map(packageName => const homebrewPromises = this.config.homebrewPackages.map(async packageName => {
async () => {
try { try {
const homebrewTracker = this.trackers.get('homebrew'); const homebrewTracker = this.trackers.get('homebrew');
const stats = await homebrewTracker.getDownloadStats(packageName); const stats = await homebrewTracker.getDownloadStats(packageName);
@@ -182,17 +174,15 @@ export class DownloadStatsAggregator implements DownloadStatsAggregator {
console.error(`Error collecting Homebrew stats for ${packageName}:`, error); console.error(`Error collecting Homebrew stats for ${packageName}:`, error);
return []; return [];
} }
} });
);
const homebrewResults = await globalRateLimiter.throttleRequests(homebrewOperations, 2, 2000); const homebrewResults = await Promise.all(homebrewPromises);
homebrewResults.forEach(stats => allStats.push(...stats)); homebrewResults.forEach(stats => allStats.push(...stats));
} }
// Collect PowerShell stats with rate limiting // Collect PowerShell stats
if (this.config.powershellModules) { if (this.config.powershellModules) {
const powershellOperations = this.config.powershellModules.map(moduleName => const powershellPromises = this.config.powershellModules.map(async moduleName => {
async () => {
try { try {
const powershellTracker = this.trackers.get('powershell'); const powershellTracker = this.trackers.get('powershell');
const stats = await powershellTracker.getDownloadStats(moduleName); const stats = await powershellTracker.getDownloadStats(moduleName);
@@ -201,17 +191,15 @@ export class DownloadStatsAggregator implements DownloadStatsAggregator {
console.error(`Error collecting PowerShell stats for ${moduleName}:`, error); console.error(`Error collecting PowerShell stats for ${moduleName}:`, error);
return []; return [];
} }
} });
);
const powershellResults = await globalRateLimiter.throttleRequests(powershellOperations, 2, 2000); const powershellResults = await Promise.all(powershellPromises);
powershellResults.forEach(stats => allStats.push(...stats)); powershellResults.forEach(stats => allStats.push(...stats));
} }
// Collect Postman stats with rate limiting // Collect Postman stats
if (this.config.postmanCollections) { if (this.config.postmanCollections) {
const postmanOperations = this.config.postmanCollections.map(collectionId => const postmanPromises = this.config.postmanCollections.map(async collectionId => {
async () => {
try { try {
const postmanTracker = this.trackers.get('postman'); const postmanTracker = this.trackers.get('postman');
const stats = await postmanTracker.getDownloadStats(collectionId); const stats = await postmanTracker.getDownloadStats(collectionId);
@@ -220,17 +208,15 @@ export class DownloadStatsAggregator implements DownloadStatsAggregator {
console.error(`Error collecting Postman stats for ${collectionId}:`, error); console.error(`Error collecting Postman stats for ${collectionId}:`, error);
return []; return [];
} }
} });
);
const postmanResults = await globalRateLimiter.throttleRequests(postmanOperations, 2, 2000); const postmanResults = await Promise.all(postmanPromises);
postmanResults.forEach(stats => allStats.push(...stats)); postmanResults.forEach(stats => allStats.push(...stats));
} }
// Collect Go stats with rate limiting // Collect Go stats
if (this.config.goModules) { if (this.config.goModules) {
const goOperations = this.config.goModules.map(moduleName => const goPromises = this.config.goModules.map(async moduleName => {
async () => {
try { try {
const goTracker = this.trackers.get('go'); const goTracker = this.trackers.get('go');
const stats = await goTracker.getDownloadStats(moduleName); const stats = await goTracker.getDownloadStats(moduleName);
@@ -239,10 +225,9 @@ export class DownloadStatsAggregator implements DownloadStatsAggregator {
console.error(`Error collecting Go stats for ${moduleName}:`, error); console.error(`Error collecting Go stats for ${moduleName}:`, error);
return []; return [];
} }
} });
);
const goResults = await globalRateLimiter.throttleRequests(goOperations, 2, 2000); const goResults = await Promise.all(goPromises);
goResults.forEach(stats => allStats.push(...stats)); goResults.forEach(stats => allStats.push(...stats));
} }
@@ -263,8 +248,7 @@ export class DownloadStatsAggregator implements DownloadStatsAggregator {
const allStats: BaseDownloadStats[] = []; const allStats: BaseDownloadStats[] = [];
const packages = this.getPackagesForPlatform(platform); const packages = this.getPackagesForPlatform(platform);
const operations = packages.map(packageName => const promises = packages.map(async packageName => {
async () => {
try { try {
const stats = await tracker.getDownloadStats(packageName); const stats = await tracker.getDownloadStats(packageName);
return stats; return stats;
@@ -272,10 +256,9 @@ export class DownloadStatsAggregator implements DownloadStatsAggregator {
console.error(`Error collecting ${platform} stats for ${packageName}:`, error); console.error(`Error collecting ${platform} stats for ${packageName}:`, error);
return []; return [];
} }
} });
);
const results = await globalRateLimiter.throttleRequests(operations, 2, 2000); const results = await Promise.all(promises);
results.forEach(stats => allStats.push(...stats)); results.forEach(stats => allStats.push(...stats));
return allStats; return allStats;

View 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');
});
});
});

View File

@@ -7,15 +7,15 @@ describe("UsageStatisticsManager", () => {
beforeEach(() => { beforeEach(() => {
const config: TrackingConfig = { const config: TrackingConfig = {
enableLogging: true, enableLogging: false, // Disable logging for tests
updateInterval: 60 * 60 * 1000, updateInterval: 60 * 60 * 1000,
npmPackages: ['lodash', 'axios'], npmPackages: ['lodash'], // Reduce to single package for faster tests
githubRepos: ['microsoft/vscode', 'facebook/react'], githubRepos: ['microsoft/vscode'], // Reduce to single repo
pythonPackages: ['requests', 'numpy'], pythonPackages: ['requests'], // Reduce to single package
homebrewPackages: ['git', 'node'], homebrewPackages: ['git'], // Reduce to single package
powershellModules: ['PowerShellGet'], powershellModules: ['PowerShellGet'],
postmanCollections: [], 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); manager = new UsageStatisticsManager(config);
}); });
@@ -31,7 +31,7 @@ describe("UsageStatisticsManager", () => {
expect(Array.isArray(report.topPackages)).toBe(true); expect(Array.isArray(report.topPackages)).toBe(true);
expect(report.topPackages.length).toBeGreaterThan(0); expect(report.topPackages.length).toBeGreaterThan(0);
expect(typeof report.platformBreakdown).toBe('object'); expect(typeof report.platformBreakdown).toBe('object');
}, 30000); // 30 second timeout }, 10000); // 10 second timeout
}); });
describe("getPlatformReport", () => { describe("getPlatformReport", () => {
@@ -54,7 +54,7 @@ describe("UsageStatisticsManager", () => {
// Should be valid JSON // Should be valid JSON
const parsed = JSON.parse(jsonReport); const parsed = JSON.parse(jsonReport);
expect(parsed).toBeDefined(); expect(parsed).toBeDefined();
}, 30000); // 30 second timeout }, 10000); // 10 second timeout
it("should export CSV report", async () => { it("should export CSV report", async () => {
const csvReport = await manager.exportReport('csv'); const csvReport = await manager.exportReport('csv');
@@ -62,7 +62,7 @@ describe("UsageStatisticsManager", () => {
expect(csvReport).toBeDefined(); expect(csvReport).toBeDefined();
expect(typeof csvReport).toBe('string'); expect(typeof csvReport).toBe('string');
expect(csvReport.includes('Platform,Package,Downloads')).toBe(true); expect(csvReport.includes('Platform,Package,Downloads')).toBe(true);
}, 30000); // 30 second timeout }, 10000); // 10 second timeout
}); });
describe("getLastUpdateTime", () => { describe("getLastUpdateTime", () => {

View File

@@ -5,6 +5,8 @@
import type { BaseDownloadStats, PlatformTracker } from '../types'; import type { BaseDownloadStats, PlatformTracker } from '../types';
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest';
import { retry } from '@octokit/plugin-retry';
import { throttling } from '@octokit/plugin-throttling';
export interface GitHubDownloadStats extends BaseDownloadStats { export interface GitHubDownloadStats extends BaseDownloadStats {
platform: 'github'; platform: 'github';
@@ -57,9 +59,10 @@ export class GitHubTracker implements PlatformTracker {
} }
private async initializeOctokit() { private async initializeOctokit() {
if (typeof window === 'undefined') { // Create Octokit with retry and throttling plugins
// Server-side: use Octokit const MyOctokit = Octokit.plugin(retry, throttling);
this.octokit = new Octokit({
this.octokit = new MyOctokit({
auth: this.token, auth: this.token,
userAgent: 'usage-statistics-tracker', userAgent: 'usage-statistics-tracker',
timeZone: 'UTC', timeZone: 'UTC',
@@ -70,17 +73,21 @@ export class GitHubTracker implements PlatformTracker {
warn: console.warn, warn: console.warn,
error: console.error error: console.error
}, },
request: { throttle: {
timeout: 10000, onRateLimit: (retryAfter: number, options: any) => {
retries: 3, console.warn(`Rate limit hit for ${options.request.url}, retrying after ${retryAfter} seconds`);
retryAfterBaseValue: 1, return true; // Retry after the specified time
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
}
},
retry: {
doNotRetry: [400, 401, 403, 404, 422], // Don't retry on these status codes
enabled: true
} }
}); });
} else {
// Client-side: use fetch API
this.octokit = null;
}
} }
async getDownloadStats(repository: string, options?: { async getDownloadStats(repository: string, options?: {
@@ -154,8 +161,10 @@ export class GitHubTracker implements PlatformTracker {
async getPackageInfo(repository: string): Promise<GitHubRepositoryInfo> { async getPackageInfo(repository: string): Promise<GitHubRepositoryInfo> {
const [owner, repo] = repository.split('/'); const [owner, repo] = repository.split('/');
if (this.octokit) { if (!this.octokit) {
// Use Octokit if available throw new Error('Octokit not initialized');
}
try { try {
const response = await this.octokit.repos.get({ const response = await this.octokit.repos.get({
owner, owner,
@@ -163,40 +172,16 @@ export class GitHubTracker implements PlatformTracker {
}); });
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
if (error.status === 403 && error.message.includes('abuse detection')) { console.error(`Error fetching repository info for ${repository}:`, error);
console.warn(`Rate limit hit for ${repository}, waiting 60 seconds...`);
await this.sleep(60000);
return this.getPackageInfo(repository); // Retry once
}
throw error; 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 (!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();
}
} }
private async getReleases(owner: string, repo: string): Promise<GitHubReleaseInfo[]> { private async getReleases(owner: string, repo: string): Promise<GitHubReleaseInfo[]> {
if (this.octokit) { if (!this.octokit) {
// Use Octokit if available throw new Error('Octokit not initialized');
}
try { try {
const response = await this.octokit.repos.listReleases({ const response = await this.octokit.repos.listReleases({
owner, owner,
@@ -205,40 +190,12 @@ export class GitHubTracker implements PlatformTracker {
}); });
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
if (error.status === 403 && error.message.includes('abuse detection')) { console.error(`Error fetching releases for ${owner}/${repo}:`, error);
console.warn(`Rate limit hit for ${owner}/${repo}, waiting 60 seconds...`);
await this.sleep(60000);
return this.getReleases(owner, repo); // Retry once
}
throw error; 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 (!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();
}
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
} }
export default GitHubTracker; export default GitHubTracker;

View File

@@ -17,14 +17,6 @@ export interface PlatformTracker {
getPackageInfo(packageName: string): Promise<any>; getPackageInfo(packageName: string): Promise<any>;
} }
export interface DownloadStatsAggregator {
aggregateStats(stats: BaseDownloadStats[]): {
totalDownloads: number;
uniquePackages: number;
platforms: string[];
};
}
export interface TrackingConfig { export interface TrackingConfig {
npmPackages?: string[]; npmPackages?: string[];
goModules?: string[]; goModules?: string[];

View File

@@ -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();