Skip to content

Commit ba37e53

Browse files
makowskidclaude
andcommitted
Add rate limiting, throttling, and 429 retry logic (v1.3.0)
Port PHP core v1.3.0 rate limiting architecture to Node.js: - SlidingWindowRateLimiter: proactive request throttling with rolling 60s window - SharpApiError: custom error class with HTTP status code - executeWithRateLimitRetry: 429 retry loop with configurable max retries - extractRateLimitHeaders: X-RateLimit-Limit/Remaining tracking - adjustIntervalForRateLimit: adaptive polling when rate limit is low - ping/quota bypass throttling; quota adapts RPM from server response - Fix parseInt radix bug in fetchResults (5 → 10) - fetchResults now throws SharpApiError on timeout instead of silently returning - Add current_subscription_reset and requests_per_minute to SubscriptionInfo DTO - 42 unit tests covering all new functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b330f03 commit ba37e53

File tree

9 files changed

+1010
-39
lines changed

9 files changed

+1010
-39
lines changed

__tests__/SharpApiCoreService.test.js

Lines changed: 512 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
const { SlidingWindowRateLimiter } = require('../src/SlidingWindowRateLimiter');
2+
3+
describe('SlidingWindowRateLimiter', () => {
4+
test('constructor sets default values', () => {
5+
const limiter = new SlidingWindowRateLimiter();
6+
expect(limiter.getMaxRequests()).toBe(60);
7+
expect(limiter.getWindowSeconds()).toBe(60);
8+
});
9+
10+
test('constructor accepts custom values', () => {
11+
const limiter = new SlidingWindowRateLimiter(10, 30);
12+
expect(limiter.getMaxRequests()).toBe(10);
13+
expect(limiter.getWindowSeconds()).toBe(30);
14+
});
15+
16+
test('canProceed returns true when under limit', () => {
17+
const limiter = new SlidingWindowRateLimiter(5, 60);
18+
expect(limiter.canProceed()).toBe(true);
19+
});
20+
21+
test('canProceed returns false when at limit', async () => {
22+
const limiter = new SlidingWindowRateLimiter(2, 60);
23+
await limiter.waitIfNeeded();
24+
await limiter.waitIfNeeded();
25+
expect(limiter.canProceed()).toBe(false);
26+
});
27+
28+
test('remaining counts correctly', async () => {
29+
const limiter = new SlidingWindowRateLimiter(5, 60);
30+
expect(limiter.remaining()).toBe(5);
31+
32+
await limiter.waitIfNeeded();
33+
expect(limiter.remaining()).toBe(4);
34+
35+
await limiter.waitIfNeeded();
36+
expect(limiter.remaining()).toBe(3);
37+
});
38+
39+
test('waitIfNeeded returns 0 when capacity available', async () => {
40+
const limiter = new SlidingWindowRateLimiter(5, 60);
41+
const waited = await limiter.waitIfNeeded();
42+
expect(waited).toBe(0);
43+
});
44+
45+
test('adaptFromServerLimit ratchets up only', () => {
46+
const limiter = new SlidingWindowRateLimiter(10, 60);
47+
48+
// Should increase
49+
limiter.adaptFromServerLimit(20);
50+
expect(limiter.getMaxRequests()).toBe(20);
51+
52+
// Should NOT decrease
53+
limiter.adaptFromServerLimit(5);
54+
expect(limiter.getMaxRequests()).toBe(20);
55+
56+
// Ignores non-numbers
57+
limiter.adaptFromServerLimit(null);
58+
expect(limiter.getMaxRequests()).toBe(20);
59+
60+
limiter.adaptFromServerLimit(undefined);
61+
expect(limiter.getMaxRequests()).toBe(20);
62+
});
63+
64+
test('setMaxRequests / getMaxRequests round-trip', () => {
65+
const limiter = new SlidingWindowRateLimiter(10, 60);
66+
limiter.setMaxRequests(100);
67+
expect(limiter.getMaxRequests()).toBe(100);
68+
});
69+
70+
test('expired timestamps are pruned', async () => {
71+
// Use a very short window for testing
72+
const limiter = new SlidingWindowRateLimiter(2, 0.1); // 100ms window
73+
74+
await limiter.waitIfNeeded();
75+
await limiter.waitIfNeeded();
76+
expect(limiter.canProceed()).toBe(false);
77+
78+
// Wait for the window to expire
79+
await new Promise((resolve) => setTimeout(resolve, 150));
80+
81+
expect(limiter.canProceed()).toBe(true);
82+
expect(limiter.remaining()).toBe(2);
83+
});
84+
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@sharpapi/sharpapi-node-core",
3-
"version": "1.0.0",
3+
"version": "1.3.0",
44
"description": "SharpAPI.com Node.js SDK Core - Shared functionality for SharpAPI clients",
55
"main": "src/index.js",
66
"scripts": {

src/Dto/SharpApiSubscriptionInfo.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,23 @@ class SharpApiSubscriptionInfo {
99
subscribed,
1010
current_subscription_start,
1111
current_subscription_end,
12+
current_subscription_reset = null,
1213
subscription_words_quota,
1314
subscription_words_used,
1415
subscription_words_used_percentage,
16+
requests_per_minute = null,
1517
}) {
1618
this.timestamp = new Date(timestamp);
1719
this.on_trial = on_trial;
1820
this.trial_ends = new Date(trial_ends);
1921
this.subscribed = subscribed;
2022
this.current_subscription_start = new Date(current_subscription_start);
2123
this.current_subscription_end = new Date(current_subscription_end);
24+
this.current_subscription_reset = current_subscription_reset ? new Date(current_subscription_reset) : null;
2225
this.subscription_words_quota = subscription_words_quota;
2326
this.subscription_words_used = subscription_words_used;
2427
this.subscription_words_used_percentage = subscription_words_used_percentage;
28+
this.requests_per_minute = requests_per_minute;
2529
}
2630

2731
toJSON() {
@@ -32,11 +36,13 @@ class SharpApiSubscriptionInfo {
3236
subscribed: this.subscribed,
3337
current_subscription_start: this.current_subscription_start,
3438
current_subscription_end: this.current_subscription_end,
39+
current_subscription_reset: this.current_subscription_reset,
3540
subscription_words_quota: this.subscription_words_quota,
3641
subscription_words_used: this.subscription_words_used,
3742
subscription_words_used_percentage: this.subscription_words_used_percentage,
43+
requests_per_minute: this.requests_per_minute,
3844
};
3945
}
4046
}
4147

42-
module.exports = { SharpApiSubscriptionInfo };
48+
module.exports = { SharpApiSubscriptionInfo };

src/Enums/SharpApiJobTypeEnum.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ class SharpApiJobTypeEnum {
8484
url: '/content/detect_profanities',
8585
};
8686

87+
static CONTENT_DETECT_AI = {
88+
value: 'content_detect_ai',
89+
url: '/content/detect_ai',
90+
};
91+
8792
static CONTENT_SUMMARIZE = {
8893
value: 'content_summarize',
8994
url: '/content/summarize',

src/Exceptions/SharpApiError.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* SharpAPI Error
3+
*
4+
* Custom error class that carries an HTTP status code.
5+
* Mirrors PHP's ApiException from sharpapi/php-core.
6+
*/
7+
class SharpApiError extends Error {
8+
/**
9+
* @param {string} message - Error message.
10+
* @param {number|null} statusCode - HTTP status code (e.g. 429, 408).
11+
*/
12+
constructor(message, statusCode = null) {
13+
super(message);
14+
this.name = 'SharpApiError';
15+
this.statusCode = statusCode;
16+
}
17+
}
18+
19+
module.exports = { SharpApiError };

0 commit comments

Comments
 (0)