@azumag/opencode-rate-limit-fallback 1.24.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,11 @@ OpenCode plugin that automatically switches to fallback models when rate limited
12
12
  - Three fallback modes: `cycle`, `stop`, and `retry-last`
13
13
  - Session model tracking for sequential fallback across multiple rate limits
14
14
  - Cooldown period to prevent immediate retry on rate-limited models
15
+ - **Exponential backoff with configurable retry policies**
16
+ - Supports immediate, exponential, and linear backoff strategies
17
+ - Jitter to prevent thundering herd problem
18
+ - Configurable retry limits and timeouts
19
+ - Retry statistics tracking
15
20
  - Toast notifications for user feedback
16
21
  - Subagent session support with automatic fallback propagation to parent sessions
17
22
  - Configurable maximum subagent nesting depth
@@ -64,6 +69,15 @@ Create a configuration file at one of these locations:
64
69
  { "providerID": "google", "modelID": "gemini-2.5-pro" },
65
70
  { "providerID": "google", "modelID": "gemini-2.5-flash" }
66
71
  ],
72
+ "retryPolicy": {
73
+ "maxRetries": 3,
74
+ "strategy": "exponential",
75
+ "baseDelayMs": 1000,
76
+ "maxDelayMs": 30000,
77
+ "jitterEnabled": true,
78
+ "jitterFactor": 0.1,
79
+ "timeoutMs": 60000
80
+ },
67
81
  "metrics": {
68
82
  "enabled": true,
69
83
  "output": {
@@ -85,6 +99,7 @@ Create a configuration file at one of these locations:
85
99
  | `fallbackModels` | array | See below | List of fallback models in priority order |
86
100
  | `maxSubagentDepth` | number | `10` | Maximum nesting depth for subagent hierarchies |
87
101
  | `enableSubagentFallback` | boolean | `true` | Enable/disable fallback for subagent sessions |
102
+ | `retryPolicy` | object | See below | Retry policy configuration (see below) |
88
103
 
89
104
  ### Fallback Modes
90
105
 
@@ -94,6 +109,64 @@ Create a configuration file at one of these locations:
94
109
  | `"stop"` | Stop and show error when all models are exhausted |
95
110
  | `"retry-last"` | Try the last model once more, then reset to first on next prompt |
96
111
 
112
+ ### Retry Policy
113
+
114
+ The retry policy controls how the plugin handles retry attempts after rate limits, with support for exponential backoff to reduce API pressure.
115
+
116
+ | Option | Type | Default | Description |
117
+ |--------|------|---------|-------------|
118
+ | `maxRetries` | number | `3` | Maximum retry attempts before giving up |
119
+ | `strategy` | string | `"immediate"` | Backoff strategy: `"immediate"`, `"exponential"`, or `"linear"` |
120
+ | `baseDelayMs` | number | `1000` | Base delay in milliseconds for backoff calculation |
121
+ | `maxDelayMs` | number | `30000` | Maximum delay in milliseconds |
122
+ | `jitterEnabled` | boolean | `false` | Add random jitter to delays to prevent thundering herd |
123
+ | `jitterFactor` | number | `0.1` | Jitter factor (0.1 = 10% variance) |
124
+ | `timeoutMs` | number | `undefined` | Overall timeout for all retry attempts (optional) |
125
+
126
+ #### Retry Strategies
127
+
128
+ **Immediate** (default, no backoff)
129
+ ```
130
+ delay = 0ms
131
+ ```
132
+ Retries immediately without any delay. This is the original behavior and maintains backward compatibility.
133
+
134
+ **Exponential** (recommended for production)
135
+ ```
136
+ delay = min(baseDelayMs * (2 ^ attempt), maxDelayMs)
137
+ delay = delay * (1 + random(-jitterFactor, jitterFactor)) // if jitter enabled
138
+ ```
139
+ Exponential backoff that doubles the delay after each attempt. This is the standard pattern for rate limit handling.
140
+
141
+ Example with `baseDelayMs: 1000`, `maxDelayMs: 30000`, and `jitterFactor: 0.1`:
142
+ - Attempt 0: ~1000ms (with jitter: 900-1100ms)
143
+ - Attempt 1: ~2000ms (with jitter: 1800-2200ms)
144
+ - Attempt 2: ~4000ms (with jitter: 3600-4400ms)
145
+ - Attempt 3: ~8000ms (with jitter: 7200-8800ms)
146
+ - Attempt 4+: ~16000ms (capped at maxDelayMs: 30000ms)
147
+
148
+ **Linear**
149
+ ```
150
+ delay = min(baseDelayMs * (attempt + 1), maxDelayMs)
151
+ delay = delay * (1 + random(-jitterFactor, jitterFactor)) // if jitter enabled
152
+ ```
153
+ Linear backoff that increases delay by a constant amount after each attempt.
154
+
155
+ Example with `baseDelayMs: 1000` and `maxDelayMs: 5000`:
156
+ - Attempt 0: ~1000ms
157
+ - Attempt 1: ~2000ms
158
+ - Attempt 2: ~3000ms
159
+ - Attempt 3: ~4000ms
160
+ - Attempt 4+: ~5000ms (capped at maxDelayMs)
161
+
162
+ #### Jitter
163
+
164
+ Jitter adds random variation to delay times to prevent the "thundering herd" problem, where multiple clients retry simultaneously and overwhelm the API.
165
+
166
+ - Recommended for production environments with multiple concurrent users
167
+ - `jitterFactor: 0.1` adds ±10% variance to delay times
168
+ - Example: With base delay of 1000ms and jitterFactor 0.1, actual delay will be 900-1100ms
169
+
97
170
  ### Default Fallback Models
98
171
 
99
172
  If no configuration is provided, the following models are used:
@@ -136,6 +209,7 @@ When OpenCode uses subagents (e.g., for complex tasks requiring specialized agen
136
209
  The plugin includes a metrics collection feature that tracks:
137
210
  - Rate limit events per provider/model
138
211
  - Fallback statistics (total, successful, failed, average duration)
212
+ - **Retry statistics** (total attempts, successes, failures, average delay)
139
213
  - Model performance (requests, successes, failures, response time)
140
214
 
141
215
  ### Metrics Configuration
@@ -191,6 +265,23 @@ Fallbacks:
191
265
  Failed: 1
192
266
  Avg Duration: 1.25s
193
267
 
268
+ Retries:
269
+ ----------------------------------------
270
+ Total: 12
271
+ Successful: 8
272
+ Failed: 4
273
+ Avg Delay: 2.5s
274
+
275
+ By Model:
276
+ anthropic/claude-3-5-sonnet-20250514:
277
+ Attempts: 5
278
+ Successes: 3
279
+ Success Rate: 60.0%
280
+ google/gemini-2.5-pro:
281
+ Attempts: 7
282
+ Successes: 5
283
+ Success Rate: 71.4%
284
+
194
285
  Model Performance:
195
286
  ----------------------------------------
196
287
  google/gemini-2.5-pro:
@@ -225,6 +316,22 @@ Model Performance:
225
316
  }
226
317
  }
227
318
  },
319
+ "retries": {
320
+ "total": 12,
321
+ "successful": 8,
322
+ "failed": 4,
323
+ "averageDelay": 2500,
324
+ "byModel": {
325
+ "anthropic/claude-3-5-sonnet-20250514": {
326
+ "attempts": 5,
327
+ "successes": 3
328
+ },
329
+ "google/gemini-2.5-pro": {
330
+ "attempts": 7,
331
+ "successes": 5
332
+ }
333
+ }
334
+ },
228
335
  "modelPerformance": {
229
336
  "google/gemini-2.5-pro": {
230
337
  "requests": 10,
@@ -248,6 +355,15 @@ anthropic/claude-3-5-sonnet-20250514,5,1739148000000,1739149740000,3500
248
355
  total,successful,failed,avg_duration_ms
249
356
  3,2,1,1250
250
357
 
358
+ === RETRIES_SUMMARY ===
359
+ total,successful,failed,avg_delay_ms
360
+ 12,8,4,2500
361
+
362
+ === RETRIES_BY_MODEL ===
363
+ model,attempts,successes,success_rate
364
+ anthropic/claude-3-5-sonnet-20250514,5,3,60.0
365
+ google/gemini-2.5-pro,7,5,71.4
366
+
251
367
  === MODEL_PERFORMANCE ===
252
368
  model,requests,successes,failures,avg_response_time_ms,success_rate
253
369
  google/gemini-2.5-pro,10,9,1,850,90.0
@@ -20,6 +20,7 @@ export declare class FallbackHandler {
20
20
  private fallbackMessages;
21
21
  private metricsManager;
22
22
  private subagentTracker;
23
+ private retryManager;
23
24
  constructor(config: PluginConfig, client: OpenCodeClient, logger: Logger, metricsManager: MetricsManager, subagentTracker: SubagentTracker);
24
25
  /**
25
26
  * Check and mark fallback in progress for deduplication
@@ -4,6 +4,7 @@
4
4
  import { SESSION_ENTRY_TTL_MS } from '../types/index.js';
5
5
  import { ModelSelector } from './ModelSelector.js';
6
6
  import { extractMessageParts, convertPartsToSDKFormat, safeShowToast, getStateKey, getModelKey, DEDUP_WINDOW_MS, STATE_TIMEOUT_MS } from '../utils/helpers.js';
7
+ import { RetryManager } from '../retry/RetryManager.js';
7
8
  /**
8
9
  * Fallback Handler class for orchestrating the fallback retry flow
9
10
  */
@@ -21,6 +22,8 @@ export class FallbackHandler {
21
22
  metricsManager;
22
23
  // Subagent tracker reference
23
24
  subagentTracker;
25
+ // Retry manager reference
26
+ retryManager;
24
27
  constructor(config, client, logger, metricsManager, subagentTracker) {
25
28
  this.config = config;
26
29
  this.client = client;
@@ -33,6 +36,8 @@ export class FallbackHandler {
33
36
  this.retryState = new Map();
34
37
  this.fallbackInProgress = new Map();
35
38
  this.fallbackMessages = new Map();
39
+ // Initialize retry manager
40
+ this.retryManager = new RetryManager(config.retryPolicy || {}, logger);
36
41
  }
37
42
  /**
38
43
  * Check and mark fallback in progress for deduplication
@@ -188,6 +193,32 @@ export class FallbackHandler {
188
193
  const state = this.getOrCreateRetryState(sessionID, lastUserMessage.info.id);
189
194
  const stateKey = getStateKey(sessionID, lastUserMessage.info.id);
190
195
  const fallbackKey = getStateKey(dedupSessionID, lastUserMessage.info.id);
196
+ // Check if retry should be attempted (using retry manager)
197
+ if (!this.retryManager.canRetry(dedupSessionID, lastUserMessage.info.id)) {
198
+ await safeShowToast(this.client, {
199
+ body: {
200
+ title: "Fallback Exhausted",
201
+ message: "All retry attempts failed",
202
+ variant: "error",
203
+ duration: 5000,
204
+ },
205
+ });
206
+ this.logger.warn('Retry exhausted', { sessionID: dedupSessionID, messageID: lastUserMessage.info.id });
207
+ this.retryState.delete(stateKey);
208
+ this.fallbackInProgress.delete(fallbackKey);
209
+ // Record retry failure metric
210
+ if (this.metricsManager) {
211
+ this.metricsManager.recordRetryFailure();
212
+ }
213
+ return;
214
+ }
215
+ // Get delay for next retry
216
+ const delay = this.retryManager.getRetryDelay(dedupSessionID, lastUserMessage.info.id);
217
+ // Apply delay if configured
218
+ if (delay > 0) {
219
+ this.logger.debug(`Applying retry delay`, { delayMs: delay });
220
+ await new Promise(resolve => setTimeout(resolve, delay));
221
+ }
191
222
  // Select the next fallback model
192
223
  const nextModel = await this.modelSelector.selectFallbackModel(currentProviderID, currentModelID, state.attemptedModels);
193
224
  // Show error if no model is available
@@ -208,6 +239,12 @@ export class FallbackHandler {
208
239
  }
209
240
  state.attemptedModels.add(getModelKey(nextModel.providerID, nextModel.modelID));
210
241
  state.lastAttemptTime = Date.now();
242
+ // Record retry attempt
243
+ this.retryManager.recordRetry(dedupSessionID, lastUserMessage.info.id, nextModel.modelID, delay);
244
+ // Record retry metric
245
+ if (this.metricsManager) {
246
+ this.metricsManager.recordRetryAttempt(nextModel.modelID, delay);
247
+ }
211
248
  // Extract message parts
212
249
  const parts = extractMessageParts(lastUserMessage);
213
250
  if (parts.length === 0) {
@@ -217,7 +254,7 @@ export class FallbackHandler {
217
254
  await safeShowToast(this.client, {
218
255
  body: {
219
256
  title: "Retrying",
220
- message: `Using ${nextModel.providerID}/${nextModel.modelID}`,
257
+ message: `Using ${nextModel.providerID}/${nextModel.modelID}${delay > 0 ? ` (after ${delay}ms)` : ''}`,
221
258
  variant: "info",
222
259
  duration: 3000,
223
260
  },
@@ -234,6 +271,11 @@ export class FallbackHandler {
234
271
  });
235
272
  // Retry with the selected model
236
273
  await this.retryWithModel(dedupSessionID, nextModel, parts, hierarchy);
274
+ // Record retry success
275
+ this.retryManager.recordSuccess(dedupSessionID, nextModel.modelID);
276
+ if (this.metricsManager) {
277
+ this.metricsManager.recordRetrySuccess(nextModel.modelID);
278
+ }
237
279
  // Clean up state
238
280
  this.retryState.delete(stateKey);
239
281
  }
@@ -245,6 +287,10 @@ export class FallbackHandler {
245
287
  error: errorMessage,
246
288
  name: errorName,
247
289
  });
290
+ // Record retry failure on error
291
+ const rootSessionID = this.subagentTracker.getRootSession(sessionID);
292
+ const targetSessionID = rootSessionID || sessionID;
293
+ this.retryManager.recordFailure(targetSessionID);
248
294
  }
249
295
  }
250
296
  /**
@@ -327,6 +373,7 @@ export class FallbackHandler {
327
373
  }
328
374
  }
329
375
  this.modelSelector.cleanupStaleEntries();
376
+ this.retryManager.cleanupStaleEntries(SESSION_ENTRY_TTL_MS);
330
377
  }
331
378
  /**
332
379
  * Clean up all resources
@@ -337,5 +384,6 @@ export class FallbackHandler {
337
384
  this.retryState.clear();
338
385
  this.fallbackInProgress.clear();
339
386
  this.fallbackMessages.clear();
387
+ this.retryManager.destroy();
340
388
  }
341
389
  }
@@ -49,6 +49,18 @@ export declare class MetricsManager {
49
49
  * Record a failed model request
50
50
  */
51
51
  recordModelFailure(providerID: string, modelID: string): void;
52
+ /**
53
+ * Record a retry attempt
54
+ */
55
+ recordRetryAttempt(modelID: string, delay: number): void;
56
+ /**
57
+ * Record a successful retry
58
+ */
59
+ recordRetrySuccess(modelID: string): void;
60
+ /**
61
+ * Record a failed retry
62
+ */
63
+ recordRetryFailure(): void;
52
64
  /**
53
65
  * Get a copy of the current metrics
54
66
  */
@@ -23,6 +23,13 @@ export class MetricsManager {
23
23
  averageDuration: 0,
24
24
  byTargetModel: new Map(),
25
25
  },
26
+ retries: {
27
+ total: 0,
28
+ successful: 0,
29
+ failed: 0,
30
+ averageDelay: 0,
31
+ byModel: new Map(),
32
+ },
26
33
  modelPerformance: new Map(),
27
34
  startedAt: Date.now(),
28
35
  generatedAt: Date.now(),
@@ -56,6 +63,13 @@ export class MetricsManager {
56
63
  averageDuration: 0,
57
64
  byTargetModel: new Map(),
58
65
  },
66
+ retries: {
67
+ total: 0,
68
+ successful: 0,
69
+ failed: 0,
70
+ averageDelay: 0,
71
+ byModel: new Map(),
72
+ },
59
73
  modelPerformance: new Map(),
60
74
  startedAt: Date.now(),
61
75
  generatedAt: Date.now(),
@@ -177,6 +191,44 @@ export class MetricsManager {
177
191
  existing.failures++;
178
192
  this.metrics.modelPerformance.set(key, existing);
179
193
  }
194
+ /**
195
+ * Record a retry attempt
196
+ */
197
+ recordRetryAttempt(modelID, delay) {
198
+ if (!this.config.enabled)
199
+ return;
200
+ this.metrics.retries.total++;
201
+ // Update average delay
202
+ const totalDelay = this.metrics.retries.averageDelay * (this.metrics.retries.total - 1);
203
+ this.metrics.retries.averageDelay = (totalDelay + delay) / this.metrics.retries.total;
204
+ // Update model-specific stats
205
+ let modelStats = this.metrics.retries.byModel.get(modelID);
206
+ if (!modelStats) {
207
+ modelStats = { attempts: 0, successes: 0 };
208
+ this.metrics.retries.byModel.set(modelID, modelStats);
209
+ }
210
+ modelStats.attempts++;
211
+ }
212
+ /**
213
+ * Record a successful retry
214
+ */
215
+ recordRetrySuccess(modelID) {
216
+ if (!this.config.enabled)
217
+ return;
218
+ this.metrics.retries.successful++;
219
+ const modelStats = this.metrics.retries.byModel.get(modelID);
220
+ if (modelStats) {
221
+ modelStats.successes++;
222
+ }
223
+ }
224
+ /**
225
+ * Record a failed retry
226
+ */
227
+ recordRetryFailure() {
228
+ if (!this.config.enabled)
229
+ return;
230
+ this.metrics.retries.failed++;
231
+ }
180
232
  /**
181
233
  * Get a copy of the current metrics
182
234
  */
@@ -209,6 +261,10 @@ export class MetricsManager {
209
261
  ...metrics.fallbacks,
210
262
  byTargetModel: Object.fromEntries(Array.from(metrics.fallbacks.byTargetModel.entries()).map(([k, v]) => [k, v])),
211
263
  },
264
+ retries: {
265
+ ...metrics.retries,
266
+ byModel: Object.fromEntries(Array.from(metrics.retries.byModel.entries()).map(([k, v]) => [k, v])),
267
+ },
212
268
  modelPerformance: Object.fromEntries(Array.from(metrics.modelPerformance.entries()).map(([k, v]) => [k, v])),
213
269
  startedAt: metrics.startedAt,
214
270
  generatedAt: metrics.generatedAt,
@@ -263,6 +319,29 @@ export class MetricsManager {
263
319
  }
264
320
  }
265
321
  lines.push("");
322
+ // Retries
323
+ lines.push("Retries:");
324
+ lines.push("-".repeat(40));
325
+ lines.push(` Total: ${metrics.retries.total}`);
326
+ lines.push(` Successful: ${metrics.retries.successful}`);
327
+ lines.push(` Failed: ${metrics.retries.failed}`);
328
+ if (metrics.retries.averageDelay > 0) {
329
+ lines.push(` Avg Delay: ${(metrics.retries.averageDelay / 1000).toFixed(2)}s`);
330
+ }
331
+ if (metrics.retries.byModel.size > 0) {
332
+ lines.push("");
333
+ lines.push(" By Model:");
334
+ for (const [model, data] of metrics.retries.byModel.entries()) {
335
+ lines.push(` ${model}:`);
336
+ lines.push(` Attempts: ${data.attempts}`);
337
+ lines.push(` Successes: ${data.successes}`);
338
+ if (data.attempts > 0) {
339
+ const successRate = ((data.successes / data.attempts) * 100).toFixed(1);
340
+ lines.push(` Success Rate: ${successRate}%`);
341
+ }
342
+ }
343
+ }
344
+ lines.push("");
266
345
  // Model Performance
267
346
  lines.push("Model Performance:");
268
347
  lines.push("-".repeat(40));
@@ -326,6 +405,29 @@ export class MetricsManager {
326
405
  ].join(","));
327
406
  }
328
407
  lines.push("");
408
+ // Retries Summary CSV
409
+ lines.push("=== RETRIES_SUMMARY ===");
410
+ lines.push(`total,successful,failed,avg_delay_ms`);
411
+ lines.push([
412
+ metrics.retries.total,
413
+ metrics.retries.successful,
414
+ metrics.retries.failed,
415
+ metrics.retries.averageDelay || 0,
416
+ ].join(","));
417
+ lines.push("");
418
+ // Retries by Model CSV
419
+ lines.push("=== RETRIES_BY_MODEL ===");
420
+ lines.push("model,attempts,successes,success_rate");
421
+ for (const [model, data] of metrics.retries.byModel.entries()) {
422
+ const successRate = data.attempts > 0 ? ((data.successes / data.attempts) * 100).toFixed(1) : "0";
423
+ lines.push([
424
+ model,
425
+ data.attempts,
426
+ data.successes,
427
+ successRate,
428
+ ].join(","));
429
+ }
430
+ lines.push("");
329
431
  // Model Performance CSV
330
432
  lines.push("=== MODEL_PERFORMANCE ===");
331
433
  lines.push("model,requests,successes,failures,avg_response_time_ms,success_rate");
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Retry Manager - Manages retry attempts with exponential backoff
3
+ */
4
+ import type { Logger } from '../../logger.js';
5
+ import type { RetryPolicy, RetryAttempt, RetryStats } from '../types/index.js';
6
+ /**
7
+ * Retry Manager class for managing retry attempts with configurable backoff strategies
8
+ */
9
+ export declare class RetryManager {
10
+ private retryAttempts;
11
+ private config;
12
+ private logger;
13
+ private retryStats;
14
+ constructor(config: Partial<RetryPolicy> | undefined, logger: Logger);
15
+ /**
16
+ * Validate retry policy configuration
17
+ */
18
+ private validateConfig;
19
+ /**
20
+ * Generate a unique key for session and message combination
21
+ */
22
+ private getKey;
23
+ /**
24
+ * Check if retry should be attempted
25
+ */
26
+ canRetry(sessionID: string, messageID: string): boolean;
27
+ /**
28
+ * Get delay for next retry attempt based on strategy
29
+ */
30
+ getRetryDelay(sessionID: string, messageID: string): number;
31
+ /**
32
+ * Calculate exponential backoff delay
33
+ */
34
+ private calculateExponentialDelay;
35
+ /**
36
+ * Calculate linear backoff delay
37
+ */
38
+ private calculateLinearDelay;
39
+ /**
40
+ * Apply jitter to delay
41
+ */
42
+ private applyJitter;
43
+ /**
44
+ * Record a retry attempt
45
+ */
46
+ recordRetry(sessionID: string, messageID: string, modelID: string, delay: number): void;
47
+ /**
48
+ * Update retry statistics
49
+ */
50
+ private updateStats;
51
+ /**
52
+ * Record a successful retry
53
+ */
54
+ recordSuccess(sessionID: string, modelID: string): void;
55
+ /**
56
+ * Record a failed retry
57
+ */
58
+ recordFailure(sessionID: string): void;
59
+ /**
60
+ * Get retry statistics for a session
61
+ */
62
+ getRetryStats(sessionID: string): RetryStats | null;
63
+ /**
64
+ * Get retry attempt information
65
+ */
66
+ getRetryAttempt(sessionID: string, messageID: string): RetryAttempt | null;
67
+ /**
68
+ * Reset retry state for a specific session/message
69
+ */
70
+ reset(sessionID: string, messageID?: string): void;
71
+ /**
72
+ * Clean up stale retry entries
73
+ */
74
+ cleanupStaleEntries(maxAge?: number): void;
75
+ /**
76
+ * Get current retry configuration
77
+ */
78
+ getConfig(): RetryPolicy;
79
+ /**
80
+ * Update retry configuration
81
+ */
82
+ updateConfig(config: Partial<RetryPolicy>): void;
83
+ /**
84
+ * Clean up all resources
85
+ */
86
+ destroy(): void;
87
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Retry Manager - Manages retry attempts with exponential backoff
3
+ */
4
+ import { DEFAULT_RETRY_POLICY, VALID_RETRY_STRATEGIES } from '../types/index.js';
5
+ /**
6
+ * Retry Manager class for managing retry attempts with configurable backoff strategies
7
+ */
8
+ export class RetryManager {
9
+ retryAttempts;
10
+ config;
11
+ logger;
12
+ retryStats;
13
+ constructor(config = {}, logger) {
14
+ this.config = { ...DEFAULT_RETRY_POLICY, ...config };
15
+ this.logger = logger;
16
+ this.retryAttempts = new Map();
17
+ this.retryStats = new Map();
18
+ // Validate config
19
+ this.validateConfig();
20
+ }
21
+ /**
22
+ * Validate retry policy configuration
23
+ */
24
+ validateConfig() {
25
+ if (!VALID_RETRY_STRATEGIES.includes(this.config.strategy)) {
26
+ this.logger.warn('Invalid strategy, using default', { strategy: this.config.strategy });
27
+ this.config.strategy = DEFAULT_RETRY_POLICY.strategy;
28
+ }
29
+ if (this.config.maxRetries < 0) {
30
+ this.logger.warn('Invalid maxRetries, using default', { maxRetries: this.config.maxRetries });
31
+ this.config.maxRetries = DEFAULT_RETRY_POLICY.maxRetries;
32
+ }
33
+ if (this.config.baseDelayMs < 0) {
34
+ this.logger.warn('Invalid baseDelayMs, using default', { baseDelayMs: this.config.baseDelayMs });
35
+ this.config.baseDelayMs = DEFAULT_RETRY_POLICY.baseDelayMs;
36
+ }
37
+ if (this.config.maxDelayMs < 0) {
38
+ this.logger.warn('Invalid maxDelayMs, using default', { maxDelayMs: this.config.maxDelayMs });
39
+ this.config.maxDelayMs = DEFAULT_RETRY_POLICY.maxDelayMs;
40
+ }
41
+ if (this.config.baseDelayMs > this.config.maxDelayMs) {
42
+ this.logger.warn('baseDelayMs > maxDelayMs, swapping values');
43
+ [this.config.baseDelayMs, this.config.maxDelayMs] = [this.config.maxDelayMs, this.config.baseDelayMs];
44
+ }
45
+ if (this.config.jitterFactor < 0 || this.config.jitterFactor > 1) {
46
+ this.logger.warn('Invalid jitterFactor, using default', { jitterFactor: this.config.jitterFactor });
47
+ this.config.jitterFactor = DEFAULT_RETRY_POLICY.jitterFactor;
48
+ }
49
+ if (this.config.timeoutMs !== undefined && this.config.timeoutMs < 0) {
50
+ this.logger.warn('Invalid timeoutMs, ignoring', { timeoutMs: this.config.timeoutMs });
51
+ this.config.timeoutMs = undefined;
52
+ }
53
+ }
54
+ /**
55
+ * Generate a unique key for session and message combination
56
+ */
57
+ getKey(sessionID, messageID) {
58
+ return `${sessionID}:${messageID}`;
59
+ }
60
+ /**
61
+ * Check if retry should be attempted
62
+ */
63
+ canRetry(sessionID, messageID) {
64
+ const key = this.getKey(sessionID, messageID);
65
+ const attempt = this.retryAttempts.get(key);
66
+ if (!attempt) {
67
+ return this.config.maxRetries > 0;
68
+ }
69
+ // Check timeout
70
+ if (this.config.timeoutMs) {
71
+ const elapsed = Date.now() - attempt.startTime;
72
+ if (elapsed > this.config.timeoutMs) {
73
+ this.logger.debug('Retry timeout exceeded', { key, elapsed, timeout: this.config.timeoutMs });
74
+ return false;
75
+ }
76
+ }
77
+ return attempt.attemptCount < this.config.maxRetries;
78
+ }
79
+ /**
80
+ * Get delay for next retry attempt based on strategy
81
+ */
82
+ getRetryDelay(sessionID, messageID) {
83
+ const key = this.getKey(sessionID, messageID);
84
+ const attempt = this.retryAttempts.get(key) || {
85
+ attemptCount: 0,
86
+ startTime: Date.now(),
87
+ delays: [],
88
+ lastAttemptTime: 0,
89
+ modelIDs: [],
90
+ };
91
+ let delay;
92
+ switch (this.config.strategy) {
93
+ case "exponential":
94
+ delay = this.calculateExponentialDelay(attempt.attemptCount);
95
+ break;
96
+ case "linear":
97
+ delay = this.calculateLinearDelay(attempt.attemptCount);
98
+ break;
99
+ case "immediate":
100
+ default:
101
+ delay = 0;
102
+ break;
103
+ }
104
+ // Apply jitter if enabled
105
+ if (this.config.jitterEnabled && delay > 0) {
106
+ delay = this.applyJitter(delay);
107
+ }
108
+ return delay;
109
+ }
110
+ /**
111
+ * Calculate exponential backoff delay
112
+ */
113
+ calculateExponentialDelay(attemptCount) {
114
+ const exponentialDelay = this.config.baseDelayMs * Math.pow(2, attemptCount);
115
+ return Math.min(exponentialDelay, this.config.maxDelayMs);
116
+ }
117
+ /**
118
+ * Calculate linear backoff delay
119
+ */
120
+ calculateLinearDelay(attemptCount) {
121
+ const linearDelay = this.config.baseDelayMs * (attemptCount + 1);
122
+ return Math.min(linearDelay, this.config.maxDelayMs);
123
+ }
124
+ /**
125
+ * Apply jitter to delay
126
+ */
127
+ applyJitter(delay) {
128
+ const jitterAmount = delay * this.config.jitterFactor;
129
+ const randomJitter = (Math.random() * 2 - 1) * jitterAmount; // -jitter to +jitter
130
+ return Math.max(0, delay + randomJitter);
131
+ }
132
+ /**
133
+ * Record a retry attempt
134
+ */
135
+ recordRetry(sessionID, messageID, modelID, delay) {
136
+ const key = this.getKey(sessionID, messageID);
137
+ const now = Date.now();
138
+ let attempt = this.retryAttempts.get(key);
139
+ if (!attempt) {
140
+ attempt = {
141
+ attemptCount: 0,
142
+ startTime: now,
143
+ delays: [],
144
+ lastAttemptTime: 0,
145
+ modelIDs: [],
146
+ };
147
+ this.retryAttempts.set(key, attempt);
148
+ }
149
+ attempt.attemptCount++;
150
+ attempt.delays.push(delay);
151
+ attempt.lastAttemptTime = now;
152
+ attempt.modelIDs.push(modelID);
153
+ // Update stats
154
+ this.updateStats(sessionID, modelID, delay, now);
155
+ this.logger.debug('Retry attempt recorded', {
156
+ key,
157
+ attemptCount: attempt.attemptCount,
158
+ delay,
159
+ modelID,
160
+ });
161
+ }
162
+ /**
163
+ * Update retry statistics
164
+ */
165
+ updateStats(sessionID, modelID, delay, now) {
166
+ let stats = this.retryStats.get(sessionID);
167
+ if (!stats) {
168
+ stats = {
169
+ totalRetries: 0,
170
+ successful: 0,
171
+ failed: 0,
172
+ averageDelay: 0,
173
+ byModel: new Map(),
174
+ startTime: now,
175
+ lastAttemptTime: now,
176
+ };
177
+ this.retryStats.set(sessionID, stats);
178
+ }
179
+ stats.totalRetries++;
180
+ stats.lastAttemptTime = now;
181
+ // Update average delay
182
+ const totalDelay = stats.averageDelay * (stats.totalRetries - 1);
183
+ stats.averageDelay = (totalDelay + delay) / stats.totalRetries;
184
+ // Update model-specific stats
185
+ let modelStats = stats.byModel.get(modelID);
186
+ if (!modelStats) {
187
+ modelStats = { attempts: 0, successes: 0 };
188
+ stats.byModel.set(modelID, modelStats);
189
+ }
190
+ modelStats.attempts++;
191
+ }
192
+ /**
193
+ * Record a successful retry
194
+ */
195
+ recordSuccess(sessionID, modelID) {
196
+ const stats = this.retryStats.get(sessionID);
197
+ if (stats) {
198
+ stats.successful++;
199
+ const modelStats = stats.byModel.get(modelID);
200
+ if (modelStats) {
201
+ modelStats.successes++;
202
+ }
203
+ }
204
+ }
205
+ /**
206
+ * Record a failed retry
207
+ */
208
+ recordFailure(sessionID) {
209
+ const stats = this.retryStats.get(sessionID);
210
+ if (stats) {
211
+ stats.failed++;
212
+ }
213
+ }
214
+ /**
215
+ * Get retry statistics for a session
216
+ */
217
+ getRetryStats(sessionID) {
218
+ return this.retryStats.get(sessionID) || null;
219
+ }
220
+ /**
221
+ * Get retry attempt information
222
+ */
223
+ getRetryAttempt(sessionID, messageID) {
224
+ const key = this.getKey(sessionID, messageID);
225
+ return this.retryAttempts.get(key) || null;
226
+ }
227
+ /**
228
+ * Reset retry state for a specific session/message
229
+ */
230
+ reset(sessionID, messageID) {
231
+ if (messageID) {
232
+ const key = this.getKey(sessionID, messageID);
233
+ this.retryAttempts.delete(key);
234
+ }
235
+ else {
236
+ // Reset all entries for this session
237
+ for (const [key] of this.retryAttempts.entries()) {
238
+ if (key.startsWith(sessionID + ':')) {
239
+ this.retryAttempts.delete(key);
240
+ }
241
+ }
242
+ this.retryStats.delete(sessionID);
243
+ }
244
+ this.logger.debug('Retry state reset', { sessionID, messageID });
245
+ }
246
+ /**
247
+ * Clean up stale retry entries
248
+ */
249
+ cleanupStaleEntries(maxAge = 3600000) {
250
+ const now = Date.now();
251
+ let cleanedCount = 0;
252
+ for (const [key, attempt] of this.retryAttempts.entries()) {
253
+ if (now - attempt.lastAttemptTime > maxAge) {
254
+ this.retryAttempts.delete(key);
255
+ cleanedCount++;
256
+ }
257
+ }
258
+ for (const [sessionID, stats] of this.retryStats.entries()) {
259
+ if (now - stats.lastAttemptTime > maxAge) {
260
+ this.retryStats.delete(sessionID);
261
+ cleanedCount++;
262
+ }
263
+ }
264
+ if (cleanedCount > 0) {
265
+ this.logger.debug('Cleaned up stale retry entries', { count: cleanedCount });
266
+ }
267
+ }
268
+ /**
269
+ * Get current retry configuration
270
+ */
271
+ getConfig() {
272
+ return { ...this.config };
273
+ }
274
+ /**
275
+ * Update retry configuration
276
+ */
277
+ updateConfig(config) {
278
+ this.config = { ...this.config, ...config };
279
+ this.validateConfig();
280
+ this.logger.debug('Retry configuration updated', { config: this.config });
281
+ }
282
+ /**
283
+ * Clean up all resources
284
+ */
285
+ destroy() {
286
+ this.retryAttempts.clear();
287
+ this.retryStats.clear();
288
+ }
289
+ }
@@ -17,6 +17,22 @@ export interface FallbackModel {
17
17
  * - "retry-last": Try the last model once, then reset to first on next prompt
18
18
  */
19
19
  export type FallbackMode = "cycle" | "stop" | "retry-last";
20
+ /**
21
+ * Retry strategy type
22
+ */
23
+ export type RetryStrategy = "immediate" | "exponential" | "linear" | "custom";
24
+ /**
25
+ * Retry policy configuration
26
+ */
27
+ export interface RetryPolicy {
28
+ maxRetries: number;
29
+ strategy: RetryStrategy;
30
+ baseDelayMs: number;
31
+ maxDelayMs: number;
32
+ jitterEnabled: boolean;
33
+ jitterFactor: number;
34
+ timeoutMs?: number;
35
+ }
20
36
  /**
21
37
  * Metrics output configuration
22
38
  */
@@ -43,6 +59,7 @@ export interface PluginConfig {
43
59
  fallbackMode: FallbackMode;
44
60
  maxSubagentDepth?: number;
45
61
  enableSubagentFallback?: boolean;
62
+ retryPolicy?: RetryPolicy;
46
63
  log?: LogConfig;
47
64
  metrics?: MetricsConfig;
48
65
  }
@@ -50,6 +67,31 @@ export interface PluginConfig {
50
67
  * Fallback state for tracking progress
51
68
  */
52
69
  export type FallbackState = "none" | "in_progress" | "completed";
70
+ /**
71
+ * Retry attempt information
72
+ */
73
+ export interface RetryAttempt {
74
+ attemptCount: number;
75
+ startTime: number;
76
+ delays: number[];
77
+ lastAttemptTime: number;
78
+ modelIDs: string[];
79
+ }
80
+ /**
81
+ * Retry statistics for tracking retry behavior
82
+ */
83
+ export interface RetryStats {
84
+ totalRetries: number;
85
+ successful: number;
86
+ failed: number;
87
+ averageDelay: number;
88
+ byModel: Map<string, {
89
+ attempts: number;
90
+ successes: number;
91
+ }>;
92
+ startTime: number;
93
+ lastAttemptTime: number;
94
+ }
53
95
  /**
54
96
  * Subagent session information
55
97
  */
@@ -141,6 +183,19 @@ export interface ModelPerformanceMetrics {
141
183
  failures: number;
142
184
  averageResponseTime?: number;
143
185
  }
186
+ /**
187
+ * Retry metrics
188
+ */
189
+ export interface RetryMetrics {
190
+ total: number;
191
+ successful: number;
192
+ failed: number;
193
+ averageDelay: number;
194
+ byModel: Map<string, {
195
+ attempts: number;
196
+ successes: number;
197
+ }>;
198
+ }
144
199
  /**
145
200
  * Complete metrics data
146
201
  */
@@ -153,6 +208,7 @@ export interface MetricsData {
153
208
  averageDuration: number;
154
209
  byTargetModel: Map<string, FallbackTargetMetrics>;
155
210
  };
211
+ retries: RetryMetrics;
156
212
  modelPerformance: Map<string, ModelPerformanceMetrics>;
157
213
  startedAt: number;
158
214
  generatedAt: number;
@@ -180,6 +236,29 @@ export type MessagePart = TextPart | FilePart;
180
236
  * SDK-compatible message part input
181
237
  */
182
238
  export type SDKMessagePartInput = TextPartInput | FilePartInput;
239
+ /**
240
+ * Toast variant type
241
+ */
242
+ export type ToastVariant = "info" | "success" | "warning" | "error";
243
+ /**
244
+ * Toast body content
245
+ */
246
+ export interface ToastBody {
247
+ title: string;
248
+ message: string;
249
+ variant: ToastVariant;
250
+ duration?: number;
251
+ }
252
+ /**
253
+ * Toast message structure
254
+ */
255
+ export interface ToastMessage {
256
+ body?: ToastBody;
257
+ title?: string;
258
+ message?: string;
259
+ variant?: ToastVariant;
260
+ duration?: number;
261
+ }
183
262
  /**
184
263
  * OpenCode client interface
185
264
  */
@@ -217,7 +296,7 @@ export type OpenCodeClient = {
217
296
  }) => Promise<unknown>;
218
297
  };
219
298
  tui?: {
220
- showToast: (toast: any) => Promise<any>;
299
+ showToast: (toast: ToastMessage) => Promise<unknown>;
221
300
  };
222
301
  };
223
302
  /**
@@ -231,10 +310,18 @@ export type PluginContext = {
231
310
  * Default fallback models
232
311
  */
233
312
  export declare const DEFAULT_FALLBACK_MODELS: FallbackModel[];
313
+ /**
314
+ * Default retry policy
315
+ */
316
+ export declare const DEFAULT_RETRY_POLICY: RetryPolicy;
234
317
  /**
235
318
  * Valid fallback modes
236
319
  */
237
320
  export declare const VALID_FALLBACK_MODES: FallbackMode[];
321
+ /**
322
+ * Valid retry strategies
323
+ */
324
+ export declare const VALID_RETRY_STRATEGIES: RetryStrategy[];
238
325
  /**
239
326
  * Valid reset intervals
240
327
  */
@@ -12,10 +12,25 @@ export const DEFAULT_FALLBACK_MODELS = [
12
12
  { providerID: "google", modelID: "gemini-2.5-pro" },
13
13
  { providerID: "google", modelID: "gemini-2.5-flash" },
14
14
  ];
15
+ /**
16
+ * Default retry policy
17
+ */
18
+ export const DEFAULT_RETRY_POLICY = {
19
+ maxRetries: 3,
20
+ strategy: "immediate",
21
+ baseDelayMs: 1000,
22
+ maxDelayMs: 30000,
23
+ jitterEnabled: false,
24
+ jitterFactor: 0.1,
25
+ };
15
26
  /**
16
27
  * Valid fallback modes
17
28
  */
18
29
  export const VALID_FALLBACK_MODES = ["cycle", "stop", "retry-last"];
30
+ /**
31
+ * Valid retry strategies
32
+ */
33
+ export const VALID_RETRY_STRATEGIES = ["immediate", "exponential", "linear", "custom"];
19
34
  /**
20
35
  * Valid reset intervals
21
36
  */
@@ -9,7 +9,7 @@ export declare const DEFAULT_CONFIG: PluginConfig;
9
9
  /**
10
10
  * Validate configuration values
11
11
  */
12
- export declare function validateConfig(config: any): PluginConfig;
12
+ export declare function validateConfig(config: Partial<PluginConfig>): PluginConfig;
13
13
  /**
14
14
  * Load and validate config from file paths
15
15
  */
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { existsSync, readFileSync } from "fs";
5
5
  import { join } from "path";
6
- import { DEFAULT_FALLBACK_MODELS, VALID_FALLBACK_MODES, VALID_RESET_INTERVALS, } from '../types/index.js';
6
+ import { DEFAULT_FALLBACK_MODELS, VALID_FALLBACK_MODES, VALID_RESET_INTERVALS, DEFAULT_RETRY_POLICY, VALID_RETRY_STRATEGIES, } from '../types/index.js';
7
7
  /**
8
8
  * Default plugin configuration
9
9
  */
@@ -12,6 +12,7 @@ export const DEFAULT_CONFIG = {
12
12
  cooldownMs: 60 * 1000,
13
13
  enabled: true,
14
14
  fallbackMode: "cycle",
15
+ retryPolicy: DEFAULT_RETRY_POLICY,
15
16
  log: {
16
17
  level: "warn",
17
18
  format: "simple",
@@ -32,11 +33,17 @@ export const DEFAULT_CONFIG = {
32
33
  export function validateConfig(config) {
33
34
  const mode = config.fallbackMode;
34
35
  const resetInterval = config.metrics?.resetInterval;
36
+ const strategy = config.retryPolicy?.strategy;
35
37
  return {
36
38
  ...DEFAULT_CONFIG,
37
39
  ...config,
38
40
  fallbackModels: config.fallbackModels || DEFAULT_CONFIG.fallbackModels,
39
- fallbackMode: VALID_FALLBACK_MODES.includes(mode) ? mode : DEFAULT_CONFIG.fallbackMode,
41
+ fallbackMode: mode && VALID_FALLBACK_MODES.includes(mode) ? mode : DEFAULT_CONFIG.fallbackMode,
42
+ retryPolicy: config.retryPolicy ? {
43
+ ...DEFAULT_CONFIG.retryPolicy,
44
+ ...config.retryPolicy,
45
+ strategy: strategy && VALID_RETRY_STRATEGIES.includes(strategy) ? strategy : DEFAULT_CONFIG.retryPolicy.strategy,
46
+ } : DEFAULT_CONFIG.retryPolicy,
40
47
  log: config.log ? { ...DEFAULT_CONFIG.log, ...config.log } : DEFAULT_CONFIG.log,
41
48
  metrics: config.metrics ? {
42
49
  ...DEFAULT_CONFIG.metrics,
@@ -45,7 +52,7 @@ export function validateConfig(config) {
45
52
  ...DEFAULT_CONFIG.metrics.output,
46
53
  ...config.metrics.output,
47
54
  } : DEFAULT_CONFIG.metrics.output,
48
- resetInterval: VALID_RESET_INTERVALS.includes(resetInterval) ? resetInterval : DEFAULT_CONFIG.metrics.resetInterval,
55
+ resetInterval: resetInterval && VALID_RESET_INTERVALS.includes(resetInterval) ? resetInterval : DEFAULT_CONFIG.metrics.resetInterval,
49
56
  } : DEFAULT_CONFIG.metrics,
50
57
  };
51
58
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * General utility functions
3
3
  */
4
- import type { MessagePart, SDKMessagePartInput } from '../types/index.js';
4
+ import type { MessagePart, SDKMessagePartInput, ToastMessage, OpenCodeClient } from '../types/index.js';
5
5
  export declare const DEDUP_WINDOW_MS = 5000;
6
6
  export declare const STATE_TIMEOUT_MS = 30000;
7
7
  /**
@@ -23,7 +23,7 @@ export declare function convertPartsToSDKFormat(parts: MessagePart[]): SDKMessag
23
23
  /**
24
24
  * Extract toast message properties with fallback values
25
25
  */
26
- export declare function getToastMessage(toast: any): {
26
+ export declare function getToastMessage(toast: ToastMessage): {
27
27
  title: string;
28
28
  message: string;
29
29
  variant: string;
@@ -31,4 +31,4 @@ export declare function getToastMessage(toast: any): {
31
31
  /**
32
32
  * Safely show toast, falling back to console logging if TUI is missing or fails
33
33
  */
34
- export declare const safeShowToast: (client: any, toast: any) => Promise<void>;
34
+ export declare const safeShowToast: (client: OpenCodeClient, toast: ToastMessage) => Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azumag/opencode-rate-limit-fallback",
3
- "version": "1.24.0",
3
+ "version": "1.27.0",
4
4
  "description": "OpenCode plugin that automatically switches to fallback models when rate limited",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",