Skip to content

Commit 499c7ba

Browse files
committed
feat: add robust error handling for transient network failures
1 parent 5fef1df commit 499c7ba

File tree

3 files changed

+226
-4
lines changed

3 files changed

+226
-4
lines changed

.talismanrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ fileignoreconfig:
77
checksum: 9d592c580a6890473e007c339d2f91c2d94ad936be1740dcef5ac500fde0cdb4
88
- filename: lib/stack/asset/index.js
99
checksum: b3358310e9cb2fb493d70890b7219db71e2202360be764465d505ef71907eefe
10+
- filename: examples/robust-error-handling.js
11+
checksum: e8a32ffbbbdba2a15f3d327273f0a5b4eb33cf84cd346562596ab697125bbbc6
1012
version: ""

examples/robust-error-handling.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Example: Configuring Robust Error Handling for Transient Network Failures
2+
// This example shows how to use the enhanced retry mechanisms in the Contentstack Management SDK
3+
4+
const contentstack = require('../lib/contentstack')
5+
6+
// Example 1: Basic configuration with enhanced network retry
7+
const clientWithBasicRetry = contentstack.client({
8+
api_key: 'your_api_key',
9+
management_token: 'your_management_token',
10+
// Enhanced network retry configuration
11+
retryOnNetworkFailure: true, // Enable network failure retries
12+
maxNetworkRetries: 3, // Max 3 attempts for network failures
13+
networkRetryDelay: 100, // Start with 100ms delay
14+
networkBackoffStrategy: 'exponential' // Use exponential backoff (100ms, 200ms, 400ms)
15+
})
16+
17+
// Example 2: Advanced configuration with fine-grained control
18+
const clientWithAdvancedRetry = contentstack.client({
19+
api_key: 'your_api_key',
20+
management_token: 'your_management_token',
21+
// Network failure retry settings
22+
retryOnNetworkFailure: true,
23+
retryOnDnsFailure: true, // Retry on DNS resolution failures (EAI_AGAIN)
24+
retryOnSocketFailure: true, // Retry on socket errors (ECONNRESET, ETIMEDOUT, etc.)
25+
retryOnHttpServerError: true, // Retry on HTTP 5xx errors
26+
maxNetworkRetries: 5, // Allow up to 5 network retries
27+
networkRetryDelay: 200, // Start with 200ms delay
28+
networkBackoffStrategy: 'exponential',
29+
30+
// Original retry settings (for non-network errors)
31+
retryOnError: true,
32+
retryLimit: 3,
33+
retryDelay: 500,
34+
35+
// Custom logging
36+
logHandler: (level, message) => {
37+
console.log(`[${level.toUpperCase()}] ${new Date().toISOString()}: ${message}`)
38+
}
39+
})
40+
41+
// Example 3: Conservative configuration for production
42+
const clientForProduction = contentstack.client({
43+
api_key: 'your_api_key',
44+
management_token: 'your_management_token',
45+
// Conservative retry settings for production
46+
retryOnNetworkFailure: true,
47+
maxNetworkRetries: 2, // Only 2 retries to avoid long delays
48+
networkRetryDelay: 300, // Longer initial delay
49+
networkBackoffStrategy: 'fixed', // Fixed delay instead of exponential
50+
51+
// Custom retry condition for additional control
52+
retryCondition: (error) => {
53+
// Custom logic: only retry on specific conditions
54+
return error.response && error.response.status >= 500
55+
}
56+
})
57+
58+
// Example usage with error handling
59+
async function demonstrateRobustErrorHandling () {
60+
try {
61+
const stack = clientWithAdvancedRetry.stack('your_stack_api_key')
62+
const contentTypes = await stack.contentType().query().find()
63+
console.log('Content types retrieved successfully:', contentTypes.items.length)
64+
} catch (error) {
65+
if (error.retryAttempts) {
66+
console.error(`Request failed after ${error.retryAttempts} retry attempts:`, error.message)
67+
console.error('Original error:', error.originalError?.code)
68+
} else {
69+
console.error('Request failed:', error.message)
70+
}
71+
}
72+
}
73+
74+
// The SDK will now automatically handle:
75+
// ✅ DNS resolution failures (EAI_AGAIN)
76+
// ✅ Socket errors (ECONNRESET, ETIMEDOUT, ECONNREFUSED)
77+
// ✅ HTTP timeouts (ECONNABORTED)
78+
// ✅ HTTP 5xx server errors (500-599)
79+
// ✅ Exponential backoff with configurable delays
80+
// ✅ Clear logging and user-friendly error messages
81+
82+
module.exports = {
83+
clientWithBasicRetry,
84+
clientWithAdvancedRetry,
85+
clientForProduction,
86+
demonstrateRobustErrorHandling
87+
}

lib/core/concurrency-queue.js

Lines changed: 137 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@ import OAuthHandler from './oauthHandler'
33
const defaultConfig = {
44
maxRequests: 5,
55
retryLimit: 5,
6-
retryDelay: 300
6+
retryDelay: 300,
7+
// Enhanced retry configuration for transient network failures
8+
retryOnError: true,
9+
retryOnNetworkFailure: true,
10+
retryOnDnsFailure: true,
11+
retryOnSocketFailure: true,
12+
retryOnHttpServerError: true,
13+
maxNetworkRetries: 3,
14+
networkRetryDelay: 100, // Base delay for network retries (ms)
15+
networkBackoffStrategy: 'exponential' // 'exponential' or 'fixed'
716
}
817

918
export function ConcurrencyQueue ({ axios, config }) {
@@ -19,13 +28,124 @@ export function ConcurrencyQueue ({ axios, config }) {
1928
} else if (config.retryDelay && config.retryDelay < 300) {
2029
throw Error('Retry Policy Error: minimum retry delay for requests is 300')
2130
}
31+
// Validate network retry configuration
32+
if (config.maxNetworkRetries && config.maxNetworkRetries < 0) {
33+
throw Error('Network Retry Policy Error: maxNetworkRetries cannot be negative')
34+
}
35+
if (config.networkRetryDelay && config.networkRetryDelay < 50) {
36+
throw Error('Network Retry Policy Error: minimum network retry delay is 50ms')
37+
}
2238
}
2339

2440
this.config = Object.assign({}, defaultConfig, config)
2541
this.queue = []
2642
this.running = []
2743
this.paused = false
2844

45+
// Helper function to determine if an error is a transient network failure
46+
const isTransientNetworkError = (error) => {
47+
// DNS resolution failures
48+
if (this.config.retryOnDnsFailure && error.code === 'EAI_AGAIN') {
49+
return { type: 'DNS_RESOLUTION', reason: 'DNS resolution failure (EAI_AGAIN)' }
50+
}
51+
52+
// Socket and connection errors
53+
if (this.config.retryOnSocketFailure) {
54+
const socketErrorCodes = ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH']
55+
if (socketErrorCodes.includes(error.code)) {
56+
return { type: 'SOCKET_ERROR', reason: `Socket error: ${error.code}` }
57+
}
58+
}
59+
60+
// Connection timeouts
61+
if (this.config.retryOnNetworkFailure && error.code === 'ECONNABORTED') {
62+
return { type: 'TIMEOUT', reason: 'Connection timeout' }
63+
}
64+
65+
// HTTP 5xx server errors
66+
if (this.config.retryOnHttpServerError && error.response && error.response.status >= 500 && error.response.status <= 599) {
67+
return { type: 'HTTP_SERVER_ERROR', reason: `HTTP ${error.response.status} server error` }
68+
}
69+
70+
return null
71+
}
72+
73+
// Calculate retry delay with backoff strategy
74+
const calculateNetworkRetryDelay = (attempt) => {
75+
const baseDelay = this.config.networkRetryDelay
76+
if (this.config.networkBackoffStrategy === 'exponential') {
77+
return baseDelay * Math.pow(2, attempt - 1)
78+
}
79+
return baseDelay // Fixed delay
80+
}
81+
82+
// Log retry attempts
83+
const logRetryAttempt = (errorInfo, attempt, delay) => {
84+
const message = `Transient ${errorInfo.type} detected: ${errorInfo.reason}. Retry attempt ${attempt}/${this.config.maxNetworkRetries} in ${delay}ms`
85+
if (this.config.logHandler) {
86+
this.config.logHandler('warning', message)
87+
} else {
88+
console.warn(`[Contentstack SDK] ${message}`)
89+
}
90+
}
91+
92+
// Log final failure
93+
const logFinalFailure = (errorInfo, maxRetries) => {
94+
const message = `Final retry failed for ${errorInfo.type}: ${errorInfo.reason}. Exceeded max retries (${maxRetries}).`
95+
if (this.config.logHandler) {
96+
this.config.logHandler('error', message)
97+
} else {
98+
console.error(`[Contentstack SDK] ${message}`)
99+
}
100+
}
101+
102+
// Enhanced retry function for network errors
103+
const retryNetworkError = (error, errorInfo, attempt) => {
104+
if (attempt > this.config.maxNetworkRetries) {
105+
logFinalFailure(errorInfo, this.config.maxNetworkRetries)
106+
// Final error message
107+
const finalError = new Error(`Network request failed after ${this.config.maxNetworkRetries} retries: ${errorInfo.reason}`)
108+
finalError.code = error.code
109+
finalError.originalError = error
110+
finalError.retryAttempts = attempt - 1
111+
return Promise.reject(finalError)
112+
}
113+
114+
const delay = calculateNetworkRetryDelay(attempt)
115+
logRetryAttempt(errorInfo, attempt, delay)
116+
117+
// Initialize retry count if not present
118+
if (!error.config.networkRetryCount) {
119+
error.config.networkRetryCount = 0
120+
}
121+
error.config.networkRetryCount = attempt
122+
123+
return new Promise((resolve, reject) => {
124+
setTimeout(() => {
125+
// Remove the failed request from running queue
126+
const runningIndex = this.running.findIndex(item => item.request === error.config)
127+
if (runningIndex !== -1) {
128+
this.running.splice(runningIndex, 1)
129+
}
130+
131+
// Retry the request
132+
axios(updateRequestConfig(error, `Network retry ${attempt}`, delay))
133+
.then(resolve)
134+
.catch((retryError) => {
135+
// Check if this is still a transient error and we can retry again
136+
const retryErrorInfo = isTransientNetworkError(retryError)
137+
if (retryErrorInfo) {
138+
retryNetworkError(retryError, retryErrorInfo, attempt + 1)
139+
.then(resolve)
140+
.catch(reject)
141+
} else {
142+
reject(retryError)
143+
}
144+
})
145+
}, delay)
146+
})
147+
}
148+
29149
// Initial shift will check running request,
30150
// and adds request to running queue if max requests are not running
31151
this.initialShift = () => {
@@ -226,12 +346,20 @@ export function ConcurrencyQueue ({ axios, config }) {
226346
const responseErrorHandler = error => {
227347
let networkError = error.config.retryCount
228348
let retryErrorType = null
349+
350+
// First, check for transient network errors
351+
const networkErrorInfo = isTransientNetworkError(error)
352+
if (networkErrorInfo && this.config.retryOnNetworkFailure) {
353+
const networkRetryCount = error.config.networkRetryCount || 0
354+
return retryNetworkError(error, networkErrorInfo, networkRetryCount + 1)
355+
}
356+
357+
// Original retry logic for non-network errors
229358
if (!this.config.retryOnError || networkError > this.config.retryLimit) {
230359
return Promise.reject(responseHandler(error))
231360
}
232-
// Check rate limit remaining header before retrying
233361

234-
// Error handling
362+
// Check rate limit remaining header before retrying
235363
const wait = this.config.retryDelay
236364
var response = error.response
237365
if (!response) {
@@ -300,7 +428,12 @@ export function ConcurrencyQueue ({ axios, config }) {
300428

301429
const updateRequestConfig = (error, retryErrorType, wait) => {
302430
const requestConfig = error.config
303-
this.config.logHandler('warning', `${retryErrorType} error occurred. Waiting for ${wait} ms before retrying...`)
431+
const message = `${retryErrorType} error occurred. Waiting for ${wait} ms before retrying...`
432+
if (this.config.logHandler) {
433+
this.config.logHandler('warning', message)
434+
} else {
435+
console.warn(`[Contentstack SDK] ${message}`)
436+
}
304437
if (axios !== undefined && axios.defaults !== undefined) {
305438
if (axios.defaults.agent === requestConfig.agent) {
306439
delete requestConfig.agent

0 commit comments

Comments
 (0)