Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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