Skip to content
This repository was archived by the owner on Nov 14, 2025. It is now read-only.

Commit 8dfb680

Browse files
sapientpantsclaude
andcommitted
feat: add automatic retry mechanism with exponential backoff and circuit breaker
Implements comprehensive retry infrastructure for resilient API calls: - Exponential backoff with configurable jitter to prevent thundering herd - Circuit breaker pattern with three states (CLOSED, OPEN, HALF_OPEN) - Retry budget to prevent retry exhaustion - Per-endpoint retry policies for optimized handling - Supports Retry-After header parsing for intelligent delays - Configuration via environment variables for all retry parameters - Integrates retry mechanism transparently into existing clients - Includes comprehensive test coverage for all retry components Closes #153 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 74870dc commit 8dfb680

20 files changed

+3166
-3
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'deepsource-mcp-server': minor
3+
---
4+
5+
feat: add automatic retry mechanism with exponential backoff and circuit breaker
6+
7+
- Implements comprehensive retry infrastructure for resilient API calls
8+
- Adds exponential backoff with configurable jitter to prevent thundering herd
9+
- Introduces circuit breaker pattern with three states (CLOSED, OPEN, HALF_OPEN)
10+
- Implements retry budget to prevent retry exhaustion
11+
- Provides per-endpoint retry policies for optimized handling
12+
- Supports Retry-After header parsing for intelligent delays
13+
- Adds configuration via environment variables for all retry parameters
14+
- Integrates retry mechanism transparently into existing clients
15+
- Includes comprehensive test coverage for all retry components

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,24 @@ For development or customization:
155155
| `LOG_FILE` | No | - | Path to log file. If not set, no logs are written |
156156
| `LOG_LEVEL` | No | `DEBUG` | Minimum log level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
157157

158-
### Performance Considerations
159-
158+
#### Retry Configuration
159+
160+
| Variable | Required | Default | Description |
161+
| ---------------------------- | -------- | ------- | --------------------------------------------------------- |
162+
| `RETRY_MAX_ATTEMPTS` | No | `3` | Maximum number of retry attempts (0-10) |
163+
| `RETRY_BASE_DELAY_MS` | No | `1000` | Base delay between retries in milliseconds (100-60000) |
164+
| `RETRY_MAX_DELAY_MS` | No | `30000` | Maximum delay between retries in milliseconds (≤300000) |
165+
| `RETRY_BUDGET_PER_MINUTE` | No | `10` | Maximum retries per minute across all endpoints (1-100) |
166+
| `CIRCUIT_BREAKER_THRESHOLD` | No | `5` | Failures before circuit opens (1-20) |
167+
| `CIRCUIT_BREAKER_TIMEOUT_MS` | No | `30000` | Time before half-open state in milliseconds (1000-300000) |
168+
169+
### Performance & Reliability
170+
171+
- **Automatic Retry**: The server implements automatic retry with exponential backoff and jitter to handle transient failures
172+
- **Circuit Breaker**: Protects against cascading failures by temporarily blocking requests to failing endpoints
173+
- **Retry Budget**: Prevents retry storms by limiting the number of retries per minute
174+
- **Rate Limiting**: Automatically handles DeepSource API rate limits with intelligent backoff
160175
- **Pagination**: Use appropriate page sizes (10-50 items) to balance response time and data completeness
161-
- **Rate Limits**: DeepSource API has rate limits. The server implements automatic retry with exponential backoff
162176
- **Caching**: Results are not cached. Consider implementing caching for frequently accessed data
163177

164178
## Available Tools

src/__tests__/config/index.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,29 @@ describe('Configuration Management', () => {
4242
apiBaseUrl: 'https://api.deepsource.io/graphql/',
4343
requestTimeout: 30000,
4444
logLevel: 'DEBUG',
45+
retry: {
46+
maxAttempts: 3,
47+
baseDelayMs: 1000,
48+
maxDelayMs: 30000,
49+
retryBudgetPerMinute: 10,
50+
circuitBreakerThreshold: 5,
51+
circuitBreakerTimeoutMs: 30000,
52+
},
4553
});
4654

4755
expect(mockLogger.debug).toHaveBeenCalledWith('Loading configuration', {
4856
hasApiKey: true,
4957
apiBaseUrl: 'https://api.deepsource.io/graphql/',
5058
requestTimeout: 30000,
5159
logLevel: 'DEBUG',
60+
retryConfig: {
61+
maxAttempts: 3,
62+
baseDelayMs: 1000,
63+
maxDelayMs: 30000,
64+
retryBudgetPerMinute: 10,
65+
circuitBreakerThreshold: 5,
66+
circuitBreakerTimeoutMs: 30000,
67+
},
5268
});
5369
});
5470

@@ -67,6 +83,14 @@ describe('Configuration Management', () => {
6783
requestTimeout: 60000,
6884
logFile: '/tmp/deepsource.log',
6985
logLevel: 'INFO',
86+
retry: {
87+
maxAttempts: 3,
88+
baseDelayMs: 1000,
89+
maxDelayMs: 30000,
90+
retryBudgetPerMinute: 10,
91+
circuitBreakerThreshold: 5,
92+
circuitBreakerTimeoutMs: 30000,
93+
},
7094
});
7195
});
7296

@@ -183,6 +207,14 @@ describe('Configuration Management', () => {
183207
apiBaseUrl: 'https://api.deepsource.io/graphql/',
184208
requestTimeout: 30000,
185209
logLevel: 'ERROR',
210+
retry: {
211+
maxAttempts: 3,
212+
baseDelayMs: 1000,
213+
maxDelayMs: 30000,
214+
retryBudgetPerMinute: 10,
215+
circuitBreakerThreshold: 5,
216+
circuitBreakerTimeoutMs: 30000,
217+
},
186218
});
187219
});
188220

@@ -201,6 +233,14 @@ describe('Configuration Management', () => {
201233
requestTimeout: 45000,
202234
logFile: '/var/log/deepsource.log',
203235
logLevel: 'WARN',
236+
retry: {
237+
maxAttempts: 3,
238+
baseDelayMs: 1000,
239+
maxDelayMs: 30000,
240+
retryBudgetPerMinute: 10,
241+
circuitBreakerThreshold: 5,
242+
circuitBreakerTimeoutMs: 30000,
243+
},
204244
});
205245
});
206246
});
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
/**
2+
* @fileoverview Enhanced base client with integrated retry mechanism
3+
* Extends BaseDeepSourceClient with automatic retry, circuit breaker, and budget management
4+
*/
5+
6+
import { BaseDeepSourceClient, DeepSourceClientConfig } from './base-client.js';
7+
import { GraphQLResponse } from '../types/graphql-responses.js';
8+
import { handleApiError } from '../utils/errors/handlers.js';
9+
import { createLogger } from '../utils/logging/logger.js';
10+
import {
11+
executeWithRetry,
12+
RetryExecutorOptions,
13+
getRetryConfig,
14+
getRetryPolicyForEndpoint,
15+
CircuitBreakerManager,
16+
RetryBudgetManager,
17+
isIdempotentGraphQLOperation,
18+
} from '../utils/retry/index.js';
19+
20+
/**
21+
* Enhanced configuration with retry settings
22+
*/
23+
export interface EnhancedClientConfig extends DeepSourceClientConfig {
24+
/** Enable retry mechanism (default: true) */
25+
enableRetry?: boolean;
26+
/** Enable circuit breaker (default: true) */
27+
enableCircuitBreaker?: boolean;
28+
/** Enable retry budget (default: true) */
29+
enableRetryBudget?: boolean;
30+
}
31+
32+
/**
33+
* Enhanced base client with retry capabilities
34+
*/
35+
export class BaseDeepSourceClientWithRetry extends BaseDeepSourceClient {
36+
protected readonly retryEnabled: boolean;
37+
protected readonly circuitBreakerManager?: CircuitBreakerManager;
38+
protected readonly retryBudgetManager?: RetryBudgetManager;
39+
protected readonly retryConfig = getRetryConfig();
40+
protected override readonly logger = createLogger('DeepSourceClientWithRetry');
41+
42+
constructor(apiKey: string, config: EnhancedClientConfig = {}) {
43+
super(apiKey, config);
44+
45+
// Initialize retry settings
46+
this.retryEnabled = config.enableRetry ?? true;
47+
48+
if (this.retryEnabled) {
49+
// Initialize circuit breaker manager if enabled
50+
if (config.enableCircuitBreaker ?? true) {
51+
this.circuitBreakerManager = new CircuitBreakerManager({
52+
failureThreshold: this.retryConfig.circuitBreakerThreshold,
53+
failureWindow: 60000,
54+
recoveryTimeout: this.retryConfig.circuitBreakerTimeoutMs,
55+
successThreshold: 3,
56+
halfOpenMaxAttempts: 5,
57+
});
58+
}
59+
60+
// Initialize retry budget manager if enabled
61+
if (config.enableRetryBudget ?? true) {
62+
this.retryBudgetManager = new RetryBudgetManager({
63+
maxRetries: this.retryConfig.retryBudgetPerMinute,
64+
windowMs: 60000,
65+
});
66+
}
67+
68+
this.logger.info('Retry mechanism initialized', {
69+
circuitBreakerEnabled: Boolean(this.circuitBreakerManager),
70+
retryBudgetEnabled: Boolean(this.retryBudgetManager),
71+
config: this.retryConfig,
72+
});
73+
}
74+
}
75+
76+
/**
77+
* Execute a GraphQL query with retry logic
78+
* @param query The GraphQL query to execute
79+
* @param variables The variables for the query
80+
* @returns The query response data
81+
* @throws {ClassifiedError} When the query fails after all retries
82+
* @protected
83+
*/
84+
protected override async executeGraphQL<T>(
85+
query: string,
86+
variables?: Record<string, unknown>
87+
): Promise<GraphQLResponse<T>> {
88+
// Detect operation type from query
89+
const operationType = this.detectOperationType(query);
90+
const endpoint = this.extractEndpointFromQuery(query);
91+
92+
// Check if operation is idempotent
93+
const isIdempotent = isIdempotentGraphQLOperation(operationType);
94+
95+
// If retry is disabled or operation is not idempotent, use base implementation
96+
if (!this.retryEnabled || !isIdempotent) {
97+
this.logger.debug('Executing without retry', {
98+
retryEnabled: this.retryEnabled,
99+
isIdempotent,
100+
operationType,
101+
});
102+
return super.executeGraphQL(query, variables);
103+
}
104+
105+
// Get retry policy for endpoint
106+
const policy = getRetryPolicyForEndpoint(endpoint);
107+
108+
// Prepare retry options
109+
const circuitBreaker = this.circuitBreakerManager?.getBreaker(endpoint);
110+
const retryBudget = this.retryBudgetManager?.getBudget(endpoint);
111+
112+
const retryOptions: RetryExecutorOptions = {
113+
endpoint,
114+
policy,
115+
...(circuitBreaker && { circuitBreaker }),
116+
...(retryBudget && { retryBudget }),
117+
onRetry: (context) => {
118+
this.logger.info('Retrying GraphQL query', {
119+
endpoint,
120+
attempt: context.attempt,
121+
totalDelay: context.totalDelay,
122+
});
123+
},
124+
};
125+
126+
// Execute with retry
127+
const result = await executeWithRetry(async () => {
128+
try {
129+
// Log query execution
130+
this.logger.debug('Executing GraphQL query with retry support', {
131+
endpoint,
132+
operationType,
133+
attempt: result?.attempts ?? 0,
134+
});
135+
136+
// Execute the query using base implementation
137+
const response = await this.client.post('', { query, variables });
138+
139+
// Check for GraphQL errors in the response
140+
if (response.data.errors) {
141+
this.logger.error('GraphQL query returned errors', {
142+
errors: response.data.errors,
143+
endpoint,
144+
});
145+
throw new Error(`GraphQL Errors: ${JSON.stringify(response.data.errors)}`);
146+
}
147+
148+
return response.data as GraphQLResponse<T>;
149+
} catch (error) {
150+
// Enhance error with classification
151+
const handledError = handleApiError(error);
152+
throw handledError;
153+
}
154+
}, retryOptions);
155+
156+
if (result.success && result.data) {
157+
this.logger.debug('GraphQL query succeeded', {
158+
endpoint,
159+
attempts: result.attempts,
160+
totalDelay: result.totalDelay,
161+
});
162+
return result.data;
163+
}
164+
165+
// Throw the final error
166+
throw result.error ?? new Error('GraphQL query failed after retries');
167+
}
168+
169+
/**
170+
* Execute a GraphQL mutation (no retry for mutations)
171+
* @param mutation The GraphQL mutation to execute
172+
* @param variables The variables for the mutation
173+
* @returns The mutation response data
174+
* @throws {ClassifiedError} When the mutation fails
175+
* @protected
176+
*/
177+
protected override async executeGraphQLMutation<T>(
178+
mutation: string,
179+
variables?: Record<string, unknown>
180+
): Promise<T> {
181+
// Mutations are not idempotent, so we don't retry them
182+
this.logger.debug('Executing mutation without retry (not idempotent)');
183+
return super.executeGraphQLMutation(mutation, variables);
184+
}
185+
186+
/**
187+
* Detect the operation type from a GraphQL query string
188+
* @param query The GraphQL query string
189+
* @returns The operation type (query, mutation, subscription)
190+
* @private
191+
*/
192+
private detectOperationType(query: string): string {
193+
const trimmed = query.trim();
194+
195+
// Check for explicit operation type
196+
if (trimmed.startsWith('query')) {
197+
return 'query';
198+
}
199+
if (trimmed.startsWith('mutation')) {
200+
return 'mutation';
201+
}
202+
if (trimmed.startsWith('subscription')) {
203+
return 'subscription';
204+
}
205+
206+
// Default to query for shorthand syntax
207+
if (trimmed.startsWith('{')) {
208+
return 'query';
209+
}
210+
211+
// Parse the query to find operation type
212+
const match = /^\s*(query|mutation|subscription)\s/i.exec(trimmed);
213+
return match && match[1] ? match[1].toLowerCase() : 'query';
214+
}
215+
216+
/**
217+
* Extract endpoint name from GraphQL query
218+
* @param query The GraphQL query string
219+
* @returns The endpoint name
220+
* @private
221+
*/
222+
private extractEndpointFromQuery(query: string): string {
223+
// Try to extract the main field being queried
224+
// Look for the first field after opening brace
225+
const fieldMatch = /{\s*(\w+)/m.exec(query);
226+
if (fieldMatch && fieldMatch[1]) {
227+
return fieldMatch[1];
228+
}
229+
230+
// Try to extract from operation name
231+
const operationMatch = /(?:query|mutation|subscription)\s+(\w+)/i.exec(query);
232+
if (operationMatch && operationMatch[1]) {
233+
return operationMatch[1];
234+
}
235+
236+
// Default to 'graphql'
237+
return 'graphql';
238+
}
239+
240+
/**
241+
* Get circuit breaker statistics
242+
* @returns Map of endpoint to circuit breaker stats
243+
*/
244+
public getCircuitBreakerStats() {
245+
return this.circuitBreakerManager?.getAllStats() ?? new Map();
246+
}
247+
248+
/**
249+
* Get retry budget statistics
250+
* @returns Map of endpoint to budget stats
251+
*/
252+
public getRetryBudgetStats() {
253+
return this.retryBudgetManager?.getAllStats() ?? new Map();
254+
}
255+
256+
/**
257+
* Reset all circuit breakers
258+
*/
259+
public resetCircuitBreakers(): void {
260+
this.circuitBreakerManager?.resetAll();
261+
this.logger.info('All circuit breakers reset');
262+
}
263+
264+
/**
265+
* Reset all retry budgets
266+
*/
267+
public resetRetryBudgets(): void {
268+
this.retryBudgetManager?.resetAll();
269+
this.logger.info('All retry budgets reset');
270+
}
271+
}

0 commit comments

Comments
 (0)