Skip to content

Commit 5ac70f3

Browse files
committed
feat: add retryWithBackoff (7 attempts) and RequestQueue throttling (1s + jitter)
- Implement retryWithBackoff with exponential backoff and +/- 30% jitter - Support for Retry-After header from server - RequestQueue with 1s minimum interval + random jitter (0.5-1.5s) - Prevents hitting 60 req/min rate limit - Up to 7 retry attempts for transient errors (429, 5xx)
1 parent 0100385 commit 5ac70f3

8 files changed

Lines changed: 1182 additions & 24 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
- 🎯 **Rate Limit Fix** - Official headers prevent aggressive rate limiting (Fixes #4)
2626
- 🔍 **Session Tracking** - Unique session/prompt IDs for proper quota recognition
2727
- 🎯 **Aligned with qwen-code** - Exposes same models as official Qwen Code CLI
28+
- ⏱️ **Request Throttling** - 1-2.5s intervals between requests (prevents 60 req/min limit)
29+
- 🔄 **Automatic Retry** - Exponential backoff with jitter for 429/5xx errors (up to 7 attempts)
30+
- 📡 **Retry-After Support** - Respects server's Retry-After header when rate limited
2831

2932
## 🆕 What's New in v1.5.0
3033

@@ -40,6 +43,21 @@
4043

4144
**Result:** Full daily quota now available without premature rate limiting.
4245

46+
### Automatic Retry & Throttling (v1.5.0+)
47+
48+
**Request Throttling:**
49+
- Minimum 1 second interval between requests
50+
- Additional 0.5-1.5s random jitter (more human-like)
51+
- Prevents hitting 60 req/min limit
52+
53+
**Automatic Retry:**
54+
- Up to 7 retry attempts for transient errors
55+
- Exponential backoff with +/- 30% jitter
56+
- Respects `Retry-After` header from server
57+
- Retries on 429 (rate limit) and 5xx (server errors)
58+
59+
**Result:** Smoother request flow and automatic recovery from rate limiting.
60+
4361
### Dynamic API Endpoint Resolution
4462

4563
The plugin now automatically detects and uses the correct API endpoint based on the `resource_url` returned by the OAuth server:

README.pt-BR.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
- 🎯 **Correção de Rate Limit** - Headers oficiais previnem rate limiting agressivo (Fix #4)
2626
- 🔍 **Session Tracking** - IDs únicos de sessão/prompt para reconhecimento de cota
2727
- 🎯 **Alinhado com qwen-code** - Expõe os mesmos modelos do Qwen Code CLI oficial
28+
- ⏱️ **Throttling de Requisições** - Intervalos de 1-2.5s entre requisições (previne limite de 60 req/min)
29+
- 🔄 **Retry Automático** - Backoff exponencial com jitter para erros 429/5xx (até 7 tentativas)
30+
- 📡 **Suporte a Retry-After** - Respeita header Retry-After do servidor quando rate limited
2831

2932
## 📋 Pré-requisitos
3033

src/index.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { spawn } from 'node:child_process';
1212

1313
import { QWEN_PROVIDER_ID, QWEN_API_CONFIG, QWEN_MODELS, QWEN_OFFICIAL_HEADERS } from './constants.js';
1414
import type { QwenCredentials } from './types.js';
15+
import type { HttpError } from './utils/retry.js';
1516
import { saveCredentials, loadCredentials, resolveBaseUrl } from './plugin/auth.js';
1617
import {
1718
generatePKCE,
@@ -22,10 +23,15 @@ import {
2223
SlowDownError,
2324
} from './qwen/oauth.js';
2425
import { logTechnicalDetail } from './errors.js';
26+
import { retryWithBackoff } from './utils/retry.js';
27+
import { RequestQueue } from './plugin/request-queue.js';
2528

2629
// Global session ID for the plugin lifetime
2730
const PLUGIN_SESSION_ID = crypto.randomUUID();
2831

32+
// Singleton request queue for throttling (shared across all requests)
33+
const requestQueue = new RequestQueue();
34+
2935
// ============================================
3036
// Helpers
3137
// ============================================
@@ -108,7 +114,48 @@ export const QwenAuthPlugin = async (_input: unknown) => {
108114
promptId: crypto.randomUUID(),
109115
source: 'opencode-qwencode-auth'
110116
})
111-
}
117+
},
118+
// Custom fetch with throttling and retry
119+
fetch: async (url: string, options?: RequestInit) => {
120+
return requestQueue.enqueue(async () => {
121+
return retryWithBackoff(
122+
async () => {
123+
// Generate new promptId for each request
124+
const headers = new Headers(options?.headers);
125+
headers.set('Authorization', `Bearer ${accessToken}`);
126+
headers.set(
127+
'X-Metadata',
128+
JSON.stringify({
129+
sessionId: PLUGIN_SESSION_ID,
130+
promptId: crypto.randomUUID(),
131+
source: 'opencode-qwencode-auth',
132+
})
133+
);
134+
135+
const response = await fetch(url, {
136+
...options,
137+
headers,
138+
});
139+
140+
if (!response.ok) {
141+
const errorText = await response.text().catch(() => '');
142+
const error = new Error(`HTTP ${response.status}: ${errorText}`) as HttpError & { status?: number };
143+
error.status = response.status;
144+
(error as any).response = response;
145+
throw error;
146+
}
147+
148+
return response;
149+
},
150+
{
151+
authType: 'qwen-oauth',
152+
maxAttempts: 7,
153+
initialDelayMs: 1500,
154+
maxDelayMs: 30000,
155+
}
156+
);
157+
});
158+
},
112159
};
113160
},
114161

src/plugin/request-queue.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Request Queue with throttling
3+
* Prevents hitting rate limits by controlling request frequency
4+
* Inspired by qwen-code-0.12.0 throttling patterns
5+
*/
6+
7+
import { createDebugLogger } from '../utils/debug-logger.js';
8+
9+
const debugLogger = createDebugLogger('REQUEST_QUEUE');
10+
11+
export class RequestQueue {
12+
private lastRequestTime = 0;
13+
private readonly MIN_INTERVAL = 1000; // 1 second
14+
private readonly JITTER_MIN = 500; // 0.5s
15+
private readonly JITTER_MAX = 1500; // 1.5s
16+
17+
/**
18+
* Get random jitter between JITTER_MIN and JITTER_MAX
19+
*/
20+
private getJitter(): number {
21+
return Math.random() * (this.JITTER_MAX - this.JITTER_MIN) + this.JITTER_MIN;
22+
}
23+
24+
/**
25+
* Execute a function with throttling
26+
* Ensures minimum interval between requests + random jitter
27+
*/
28+
async enqueue<T>(fn: () => Promise<T>): Promise<T> {
29+
const elapsed = Date.now() - this.lastRequestTime;
30+
const waitTime = Math.max(0, this.MIN_INTERVAL - elapsed);
31+
32+
if (waitTime > 0) {
33+
const jitter = this.getJitter();
34+
const totalWait = waitTime + jitter;
35+
36+
debugLogger.info(
37+
`Throttling: waiting ${totalWait.toFixed(0)}ms (${waitTime.toFixed(0)}ms + ${jitter.toFixed(0)}ms jitter)`
38+
);
39+
40+
await new Promise(resolve => setTimeout(resolve, totalWait));
41+
}
42+
43+
this.lastRequestTime = Date.now();
44+
return fn();
45+
}
46+
}

src/qwen/oauth.ts

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { randomBytes, createHash, randomUUID } from 'node:crypto';
1010
import { QWEN_OAUTH_CONFIG } from '../constants.js';
1111
import type { QwenCredentials } from '../types.js';
1212
import { QwenAuthError, logTechnicalDetail } from '../errors.js';
13+
import { retryWithBackoff, getErrorStatus } from '../utils/retry.js';
1314

1415
/**
1516
* Erro lançado quando o servidor pede slow_down (RFC 8628)
@@ -178,6 +179,7 @@ export function tokenResponseToCredentials(tokenResponse: TokenResponse): QwenCr
178179

179180
/**
180181
* Refresh the access token using refresh_token grant
182+
* Includes automatic retry for transient errors (429, 5xx)
181183
*/
182184
export async function refreshAccessToken(refreshToken: string): Promise<QwenCredentials> {
183185
const bodyData = {
@@ -186,31 +188,55 @@ export async function refreshAccessToken(refreshToken: string): Promise<QwenCred
186188
client_id: QWEN_OAUTH_CONFIG.clientId,
187189
};
188190

189-
const response = await fetch(QWEN_OAUTH_CONFIG.tokenEndpoint, {
190-
method: 'POST',
191-
headers: {
192-
'Content-Type': 'application/x-www-form-urlencoded',
193-
Accept: 'application/json',
194-
},
195-
body: objectToUrlEncoded(bodyData),
196-
});
197-
198-
if (!response.ok) {
199-
const errorText = await response.text();
200-
logTechnicalDetail(`Token refresh HTTP ${response.status}: ${errorText}`);
201-
throw new QwenAuthError('refresh_failed', `HTTP ${response.status}: ${errorText}`);
202-
}
191+
return retryWithBackoff(
192+
async () => {
193+
const response = await fetch(QWEN_OAUTH_CONFIG.tokenEndpoint, {
194+
method: 'POST',
195+
headers: {
196+
'Content-Type': 'application/x-www-form-urlencoded',
197+
Accept: 'application/json',
198+
},
199+
body: objectToUrlEncoded(bodyData),
200+
});
201+
202+
if (!response.ok) {
203+
const errorText = await response.text();
204+
logTechnicalDetail(`Token refresh HTTP ${response.status}: ${errorText}`);
205+
206+
// Don't retry on invalid_grant (refresh token expired/revoked)
207+
if (errorText.includes('invalid_grant')) {
208+
throw new QwenAuthError('invalid_grant', 'Refresh token expired or revoked');
209+
}
210+
211+
throw new QwenAuthError('refresh_failed', `HTTP ${response.status}: ${errorText}`);
212+
}
203213

204-
const data = await response.json() as TokenResponse;
214+
const data = await response.json() as TokenResponse;
205215

206-
return {
207-
accessToken: data.access_token,
208-
tokenType: data.token_type || 'Bearer',
209-
refreshToken: data.refresh_token || refreshToken,
210-
resourceUrl: data.resource_url,
211-
expiryDate: Date.now() + data.expires_in * 1000,
212-
scope: data.scope,
213-
};
216+
return {
217+
accessToken: data.access_token,
218+
tokenType: data.token_type || 'Bearer',
219+
refreshToken: data.refresh_token || refreshToken,
220+
resourceUrl: data.resource_url,
221+
expiryDate: Date.now() + data.expires_in * 1000,
222+
scope: data.scope,
223+
};
224+
},
225+
{
226+
maxAttempts: 5,
227+
initialDelayMs: 1000,
228+
maxDelayMs: 15000,
229+
shouldRetryOnError: (error) => {
230+
// Don't retry on invalid_grant errors
231+
if (error.message.includes('invalid_grant')) {
232+
return false;
233+
}
234+
// Retry on 429 or 5xx errors
235+
const status = getErrorStatus(error);
236+
return status === 429 || (status !== undefined && status >= 500 && status < 600);
237+
},
238+
}
239+
);
214240
}
215241

216242
/**

src/utils/debug-logger.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Debug logger utility
3+
* Only outputs when OPENCODE_QWEN_DEBUG=1 is set
4+
*/
5+
6+
const DEBUG_ENABLED = process.env.OPENCODE_QWEN_DEBUG === '1';
7+
8+
export interface DebugLogger {
9+
info: (message: string, ...args: unknown[]) => void;
10+
warn: (message: string, ...args: unknown[]) => void;
11+
error: (message: string, ...args: unknown[]) => void;
12+
debug: (message: string, ...args: unknown[]) => void;
13+
}
14+
15+
export function createDebugLogger(prefix: string): DebugLogger {
16+
const logPrefix = `[${prefix}]`;
17+
18+
return {
19+
info: (message: string, ...args: unknown[]) => {
20+
if (DEBUG_ENABLED) {
21+
console.log(`${logPrefix} [INFO] ${message}`, ...args);
22+
}
23+
},
24+
warn: (message: string, ...args: unknown[]) => {
25+
if (DEBUG_ENABLED) {
26+
console.warn(`${logPrefix} [WARN] ${message}`, ...args);
27+
}
28+
},
29+
error: (message: string, ...args: unknown[]) => {
30+
if (DEBUG_ENABLED) {
31+
console.error(`${logPrefix} [ERROR] ${message}`, ...args);
32+
}
33+
},
34+
debug: (message: string, ...args: unknown[]) => {
35+
if (DEBUG_ENABLED) {
36+
console.log(`${logPrefix} [DEBUG] ${message}`, ...args);
37+
}
38+
},
39+
};
40+
}

0 commit comments

Comments
 (0)