Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion agents/metadata-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The Metadata Agent helps teams:
2. **Sync** labels and project fields with confidence scoring
3. **Validate** release readiness across Tier 1, Tier 2, and Tier 3 checks
4. **Discover** and learn label taxonomy
5. **Recover** from errors with intelligent retry and suggestions
5. **Recover** from errors with intelligent retry, adaptive backoff, and quota forecasting

Perfect for:

Expand Down Expand Up @@ -302,6 +302,12 @@ Auto-apply threshold: **85%+** confidence (customizable)
Hit rate limit → Wait until reset → Retry → Continue
```

The Phase 2.4 rate limit modules provide:

- `lib/rate-limit/rate-limit-tracker.js` — independent tracking for core, GraphQL, and search quotas
- `lib/rate-limit/backoff-calculator.js` — adaptive exponential backoff with jitter and Retry-After support
- `lib/rate-limit/quota-monitor.js` — quota health states and recovery estimates

### Missing Labels

```
Expand Down
19 changes: 19 additions & 0 deletions agents/metadata-agent/__tests__/api/retry-strategy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,25 @@ describe("RetryStrategy", () => {
expect(delay).toBeGreaterThan(3000);
});

test("uses adaptive delay for search rate limit pressure", () => {
const fastStrategy = new RetryStrategy({
initialDelayMs: 100,
maxDelayMs: 5000,
jitterFactor: 0,
});

const error = new Error("Rate limit exceeded");
error.status = 429;
error.response = { headers: { "x-ratelimit-resource": "search" } };

const delay = fastStrategy.getRetryDelay(error, 1, {
quotaRemainingPercent: 5,
});

const expectedDelay = 100 * 2 * 3 * (1 + 0.95);
expect(delay).toBeCloseTo(expectedDelay, 0);
});

test("handles missing response headers", () => {
const error = new Error("Generic error");
const delay = strategy.getRetryDelay(error, 0);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const BackoffCalculator = require("../../lib/rate-limit/backoff-calculator");

describe("BackoffCalculator", () => {
const originalRandom = Math.random;

afterEach(() => {
Math.random = originalRandom;
});

test("calculates exponential delay with jitter", () => {
Math.random = jest.fn(() => 0.75);
const calculator = new BackoffCalculator({
initialDelayMs: 1000,
maxDelayMs: 60000,
backoffFactor: 2,
jitterFactor: 0.1,
});

const delay = calculator.calculateDelay({ attemptNumber: 2 });

expect(delay).toBeGreaterThan(4000);
expect(delay).toBeLessThan(4500);
});

test("prefers Retry-After header over computed delay", () => {
const calculator = new BackoffCalculator({ maxDelayMs: 60000 });
const error = new Error("Rate limited");
error.response = { headers: { "retry-after": "30" } };

const delay = calculator.calculateDelay({ attemptNumber: 0, error });

expect(delay).toBe(30000);
});

test("adapts delay for rate limit pressure", () => {
Math.random = jest.fn(() => 0.5);
const calculator = new BackoffCalculator({
initialDelayMs: 1000,
jitterFactor: 0,
maxDelayMs: 60000,
});

const error = new Error("API rate limit exceeded");
error.status = 429;

const normal = calculator.calculateDelay({ attemptNumber: 1, error });
const pressured = calculator.calculateDelay({
attemptNumber: 1,
error,
rateLimitType: "search",
quotaRemainingPercent: 2,
});

expect(pressured).toBeGreaterThan(normal);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const QuotaMonitor = require("../../lib/rate-limit/quota-monitor");
const RateLimitTracker = require("../../lib/rate-limit/rate-limit-tracker");

describe("QuotaMonitor", () => {
let tracker;
let monitor;

beforeEach(() => {
tracker = new RateLimitTracker();
monitor = new QuotaMonitor(tracker);
});

test("throws when tracker is missing", () => {
expect(() => new QuotaMonitor()).toThrow("RateLimitTracker is required");
});

test("returns warning state when quota is low", () => {
tracker.limits.search.remaining = 4;

const status = monitor.getStatus("search");

expect(status.state).toBe("warning");
expect(status.percentage).toBe(13);
});

test("returns exhausted state when no quota remains", () => {
tracker.limits.graphql.remaining = 0;
tracker.limits.graphql.reset = new Date(Date.now() + 20000);

const status = monitor.getStatus("graphql", 1);

expect(status.state).toBe("exhausted");
expect(status.hasQuota).toBe(false);
expect(status.recoveryInMs).toBeGreaterThan(15000);
});

test("reports global bottleneck", () => {
tracker.limits.core.remaining = 4900;
tracker.limits.graphql.remaining = 500;
tracker.limits.search.remaining = 3;

const status = monitor.getGlobalStatus();

expect(status.bottleneck).toBe("search");
expect(status.search.state).toBe("warning");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const RateLimitTracker = require("../../lib/rate-limit/rate-limit-tracker");

describe("RateLimitTracker", () => {
let tracker;

beforeEach(() => {
tracker = new RateLimitTracker();
});

test("tracks core/graphql/search from rate limit response", () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
const limits = tracker.updateFromRateLimitResponse({
rate_limit: { limit: 5000, remaining: 4900, reset: nowInSeconds + 3600 },
resources: {
graphql: { limit: 5000, remaining: 4700, reset: nowInSeconds + 3600 },
search: { limit: 30, remaining: 20, reset: nowInSeconds + 60 },
},
});

expect(limits.core.remaining).toBe(4900);
expect(limits.graphql.remaining).toBe(4700);
expect(limits.search.remaining).toBe(20);
});

test("updates limit state from headers", () => {
const update = tracker.updateFromHeaders("search", {
"x-ratelimit-limit": "30",
"x-ratelimit-remaining": "5",
"x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + 30),
});

expect(update.limit).toBe(30);
expect(update.remaining).toBe(5);
expect(update.reset).toBeInstanceOf(Date);
});

test("estimates quota recovery when quota is exhausted", () => {
tracker.limits.search.remaining = 0;
tracker.limits.search.reset = new Date(Date.now() + 15000);

const estimate = tracker.estimateQuotaRecovery("search", 1);

expect(estimate.hasQuota).toBe(false);
expect(estimate.recoveryInMs).toBeGreaterThan(10000);
expect(estimate.recoveryInMs).toBeLessThanOrEqual(15000);
});

test("identifies the most constrained quota pool", () => {
tracker.limits.core.remaining = 4500;
tracker.limits.graphql.remaining = 2500;
tracker.limits.search.remaining = 3;

expect(tracker.getMostConstrainedType()).toBe("search");
});
});
125 changes: 6 additions & 119 deletions agents/metadata-agent/lib/api/rate-limiter.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,19 @@
const RateLimitTracker = require("../rate-limit/rate-limit-tracker");

/**
* Rate limit monitor for GitHub API.
* Tracks core, GraphQL, and search rate limits independently.
* Provides quota recovery estimation and threshold alerts.
*/

class RateLimiter {
class RateLimiter extends RateLimitTracker {
constructor(client) {
if (!client) {
throw new Error("Octokit client is required");
}

super();
this.client = client;
this.limits = {
core: { limit: 5000, remaining: 5000, reset: null },
graphql: { limit: 5000, remaining: 5000, reset: null },
search: { limit: 30, remaining: 30, reset: null },
};

this.thresholds = {
core: 100, // Alert at 100 requests remaining
graphql: 100,
search: 5, // Alert at 5 searches remaining
};

this.lastUpdate = null;
}

/**
Expand All @@ -33,35 +23,7 @@ class RateLimiter {
async updateRateLimits() {
try {
const response = await this.client.rateLimit.get();
const { rate_limit: rateLimit, resources } = response.data;

// Update core limit
this.limits.core = {
limit: rateLimit.limit,
remaining: rateLimit.remaining,
reset: new Date(rateLimit.reset * 1000),
};

// Update GraphQL limit
if (resources.graphql) {
this.limits.graphql = {
limit: resources.graphql.limit,
remaining: resources.graphql.remaining,
reset: new Date(resources.graphql.reset * 1000),
};
}

// Update search limit
if (resources.search) {
this.limits.search = {
limit: resources.search.limit,
remaining: resources.search.remaining,
reset: new Date(resources.search.reset * 1000),
};
}

this.lastUpdate = new Date();
return this.limits;
return this.updateFromRateLimitResponse(response.data);
} catch (error) {
throw new Error(`Failed to update rate limits: ${error.message}`, {
cause: error,
Expand All @@ -74,74 +36,14 @@ class RateLimiter {
* @param {string} type - API type: 'core', 'graphql', or 'search'
* @returns {Object} Rate limit information
*/
getLimit(type = "core") {
if (!this.limits[type]) {
throw new Error(`Unknown rate limit type: ${type}`);
}
return { ...this.limits[type] };
}

/**
* Get all rate limits
* @returns {Object} All rate limits
*/
getAllLimits() {
return {
core: { ...this.limits.core },
graphql: { ...this.limits.graphql },
search: { ...this.limits.search },
};
}

/**
* Check if rate limit is below threshold
* @param {string} type - API type
* @returns {boolean} True if below threshold
*/
isBelowThreshold(type = "core") {
const limit = this.getLimit(type);
return limit.remaining <= this.thresholds[type];
}

/**
* Get time until rate limit reset for a specific type
* @param {string} type - API type
* @returns {number} Milliseconds until reset
*/
getTimeUntilReset(type = "core") {
const limit = this.getLimit(type);
if (!limit.reset) return 0;
return Math.max(0, limit.reset - new Date());
}

/**
* Estimate quota recovery time (when we'll have quota available again)
* @param {string} type - API type
* @returns {number} Milliseconds until quota is available
*/
async estimateQuotaRecovery(type = "core") {
await this.updateRateLimits();

const limit = this.getLimit(type);

// If we have quota, return immediately
if (limit.remaining > 0) {
return 0;
}

// Otherwise, return time until reset
return this.getTimeUntilReset(type);
}

/**
* Get percentage of quota remaining
* @param {string} type - API type
* @returns {number} Percentage (0-100)
*/
getPercentageRemaining(type = "core") {
const limit = this.getLimit(type);
if (limit.limit === 0) return 0;
return Math.round((limit.remaining / limit.limit) * 100);
return super.estimateQuotaRecovery(type, 1).recoveryInMs;
}

/**
Expand Down Expand Up @@ -191,21 +93,6 @@ class RateLimiter {
};
}

/**
* Set custom threshold for a rate limit type
* @param {string} type - API type
* @param {number} threshold - Threshold value
*/
setThreshold(type, threshold) {
if (!this.thresholds[type]) {
throw new Error(`Unknown rate limit type: ${type}`);
}
if (threshold < 0) {
throw new Error("Threshold must be non-negative");
}
this.thresholds[type] = threshold;
}

/**
* Reset all rate limits to defaults
*/
Expand Down
Loading
Loading