navis.js 1.0.0 → 3.0.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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Circuit Breaker - Prevents cascading failures in microservices
3
+ * v2: Circuit breaker pattern implementation
4
+ */
5
+
6
+ class CircuitBreaker {
7
+ constructor(options = {}) {
8
+ this.failureThreshold = options.failureThreshold || 5; // Open circuit after 5 failures
9
+ this.resetTimeout = options.resetTimeout || 60000; // 60 seconds
10
+ this.monitoringWindow = options.monitoringWindow || 10000; // 10 seconds
11
+
12
+ this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
13
+ this.failureCount = 0;
14
+ this.successCount = 0;
15
+ this.lastFailureTime = null;
16
+ this.nextAttemptTime = null;
17
+ }
18
+
19
+ /**
20
+ * Record a successful request
21
+ */
22
+ recordSuccess() {
23
+ if (this.state === 'HALF_OPEN') {
24
+ this.successCount++;
25
+ if (this.successCount >= 2) {
26
+ // Reset to CLOSED after 2 successes in HALF_OPEN
27
+ this.state = 'CLOSED';
28
+ this.failureCount = 0;
29
+ this.successCount = 0;
30
+ }
31
+ } else if (this.state === 'CLOSED') {
32
+ this.failureCount = 0; // Reset failure count on success
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Record a failed request
38
+ */
39
+ recordFailure() {
40
+ this.failureCount++;
41
+ this.lastFailureTime = Date.now();
42
+
43
+ if (this.state === 'CLOSED' && this.failureCount >= this.failureThreshold) {
44
+ // Open the circuit
45
+ this.state = 'OPEN';
46
+ this.nextAttemptTime = Date.now() + this.resetTimeout;
47
+ } else if (this.state === 'HALF_OPEN') {
48
+ // Failed in half-open, go back to open
49
+ this.state = 'OPEN';
50
+ this.nextAttemptTime = Date.now() + this.resetTimeout;
51
+ this.successCount = 0;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Check if request should be allowed
57
+ * @returns {boolean} - true if request should proceed
58
+ */
59
+ canAttempt() {
60
+ if (this.state === 'CLOSED') {
61
+ return true;
62
+ }
63
+
64
+ if (this.state === 'OPEN') {
65
+ if (Date.now() >= this.nextAttemptTime) {
66
+ // Transition to HALF_OPEN
67
+ this.state = 'HALF_OPEN';
68
+ this.successCount = 0;
69
+ return true;
70
+ }
71
+ return false; // Circuit is open, reject request
72
+ }
73
+
74
+ if (this.state === 'HALF_OPEN') {
75
+ return true; // Allow limited requests in half-open state
76
+ }
77
+
78
+ return false;
79
+ }
80
+
81
+ /**
82
+ * Get current circuit state
83
+ */
84
+ getState() {
85
+ return {
86
+ state: this.state,
87
+ failureCount: this.failureCount,
88
+ successCount: this.successCount,
89
+ lastFailureTime: this.lastFailureTime,
90
+ nextAttemptTime: this.nextAttemptTime,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Reset circuit breaker
96
+ */
97
+ reset() {
98
+ this.state = 'CLOSED';
99
+ this.failureCount = 0;
100
+ this.successCount = 0;
101
+ this.lastFailureTime = null;
102
+ this.nextAttemptTime = null;
103
+ }
104
+ }
105
+
106
+ module.exports = CircuitBreaker;
107
+
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Retry utility with exponential backoff
3
+ * v2: Retry logic for resilient service calls
4
+ */
5
+
6
+ /**
7
+ * Sleep for specified milliseconds
8
+ * @private
9
+ */
10
+ function sleep(ms) {
11
+ return new Promise(resolve => setTimeout(resolve, ms));
12
+ }
13
+
14
+ /**
15
+ * Calculate exponential backoff delay
16
+ * @param {number} attempt - Current attempt number (0-indexed)
17
+ * @param {number} baseDelay - Base delay in milliseconds
18
+ * @param {number} maxDelay - Maximum delay in milliseconds
19
+ * @param {number} jitter - Random jitter factor (0-1)
20
+ */
21
+ function calculateBackoff(attempt, baseDelay = 1000, maxDelay = 30000, jitter = 0.1) {
22
+ const exponentialDelay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
23
+ const jitterAmount = exponentialDelay * jitter * Math.random();
24
+ return Math.floor(exponentialDelay + jitterAmount);
25
+ }
26
+
27
+ /**
28
+ * Retry a function with exponential backoff
29
+ * @param {Function} fn - Function to retry (must return a Promise)
30
+ * @param {Object} options - Retry options
31
+ * @param {number} options.maxRetries - Maximum number of retry attempts (default: 3)
32
+ * @param {number} options.baseDelay - Base delay in milliseconds (default: 1000)
33
+ * @param {number} options.maxDelay - Maximum delay in milliseconds (default: 30000)
34
+ * @param {number} options.jitter - Jitter factor 0-1 (default: 0.1)
35
+ * @param {Function} options.shouldRetry - Function to determine if error should be retried (default: retry on all errors)
36
+ * @returns {Promise} - Result of the function
37
+ */
38
+ async function retry(fn, options = {}) {
39
+ const {
40
+ maxRetries = 3,
41
+ baseDelay = 1000,
42
+ maxDelay = 30000,
43
+ jitter = 0.1,
44
+ shouldRetry = () => true, // Retry on all errors by default
45
+ } = options;
46
+
47
+ let lastError;
48
+
49
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
50
+ try {
51
+ const result = await fn();
52
+ return result;
53
+ } catch (error) {
54
+ lastError = error;
55
+
56
+ // Check if we should retry this error
57
+ if (!shouldRetry(error, attempt)) {
58
+ throw error;
59
+ }
60
+
61
+ // Don't wait after the last attempt
62
+ if (attempt < maxRetries) {
63
+ const delay = calculateBackoff(attempt, baseDelay, maxDelay, jitter);
64
+ await sleep(delay);
65
+ }
66
+ }
67
+ }
68
+
69
+ throw lastError;
70
+ }
71
+
72
+ /**
73
+ * Check if HTTP status code should be retried
74
+ * @param {number} statusCode - HTTP status code
75
+ * @returns {boolean} - true if status code should be retried
76
+ */
77
+ function shouldRetryHttpStatus(statusCode) {
78
+ // Retry on 5xx errors and 429 (Too Many Requests)
79
+ return statusCode >= 500 || statusCode === 429;
80
+ }
81
+
82
+ module.exports = {
83
+ retry,
84
+ calculateBackoff,
85
+ shouldRetryHttpStatus,
86
+ sleep,
87
+ };
88
+
@@ -1,105 +1,235 @@
1
- const http = require('http');
2
- const https = require('https');
3
-
4
- /**
5
- * ServiceClient - Lightweight HTTP client for service-to-service calls
6
- * v1: Basic get() and post() with timeout support
7
- */
8
-
9
- class ServiceClient {
10
- constructor(baseUrl, options = {}) {
11
- this.baseUrl = baseUrl;
12
- this.timeout = options.timeout || 5000; // Default 5s timeout
13
- // TODO v2: Add retry logic
14
- // TODO v2: Add circuit breaker
15
- // TODO v2: Add config-based services
16
- }
17
-
18
- /**
19
- * Make HTTP request
20
- * @private
21
- */
22
- _request(method, path, data = null, options = {}) {
23
- return new Promise((resolve, reject) => {
24
- const url = new URL(path, this.baseUrl);
25
- const isHttps = url.protocol === 'https:';
26
- const client = isHttps ? https : http;
27
-
28
- const requestOptions = {
29
- hostname: url.hostname,
30
- port: url.port || (isHttps ? 443 : 80),
31
- path: url.pathname + url.search,
32
- method,
33
- headers: {
34
- 'Content-Type': 'application/json',
35
- ...options.headers,
36
- },
37
- timeout: options.timeout || this.timeout,
38
- };
39
-
40
- const req = client.request(requestOptions, (res) => {
41
- let body = '';
42
-
43
- res.on('data', (chunk) => {
44
- body += chunk;
45
- });
46
-
47
- res.on('end', () => {
48
- try {
49
- const parsedBody = body ? JSON.parse(body) : {};
50
- resolve({
51
- statusCode: res.statusCode,
52
- headers: res.headers,
53
- data: parsedBody,
54
- });
55
- } catch (err) {
56
- resolve({
57
- statusCode: res.statusCode,
58
- headers: res.headers,
59
- data: body,
60
- });
61
- }
62
- });
63
- });
64
-
65
- req.on('error', (err) => {
66
- reject(err);
67
- });
68
-
69
- req.on('timeout', () => {
70
- req.destroy();
71
- reject(new Error('Request timeout'));
72
- });
73
-
74
- if (data) {
75
- req.write(JSON.stringify(data));
76
- }
77
-
78
- req.end();
79
- });
80
- }
81
-
82
- /**
83
- * GET request
84
- * @param {string} path - Request path
85
- * @param {Object} options - Request options
86
- */
87
- async get(path, options = {}) {
88
- return this._request('GET', path, null, options);
89
- }
90
-
91
- /**
92
- * POST request
93
- * @param {string} path - Request path
94
- * @param {Object} data - Request body data
95
- * @param {Object} options - Request options
96
- */
97
- async post(path, data, options = {}) {
98
- return this._request('POST', path, data, options);
99
- }
100
-
101
- // TODO v2: Add PUT, DELETE, PATCH methods
102
- // TODO v2: Add service discovery integration
103
- }
104
-
1
+ const http = require('http');
2
+ const https = require('https');
3
+ const CircuitBreaker = require('./circuit-breaker');
4
+ const { retry, shouldRetryHttpStatus } = require('./retry');
5
+
6
+ /**
7
+ * ServiceClient - Lightweight HTTP client for service-to-service calls
8
+ * v2: Enhanced with retry logic, circuit breaker, and additional HTTP methods
9
+ */
10
+
11
+ class ServiceClient {
12
+ constructor(baseUrl, options = {}) {
13
+ this.baseUrl = baseUrl;
14
+ this.timeout = options.timeout || 5000; // Default 5s timeout
15
+
16
+ // Retry configuration
17
+ this.retryConfig = {
18
+ maxRetries: options.maxRetries !== undefined ? options.maxRetries : 3,
19
+ baseDelay: options.retryBaseDelay || 1000,
20
+ maxDelay: options.retryMaxDelay || 30000,
21
+ enabled: options.retry !== false, // Enabled by default
22
+ };
23
+
24
+ // Circuit breaker configuration
25
+ this.circuitBreakerEnabled = options.circuitBreaker !== false; // Enabled by default
26
+ if (this.circuitBreakerEnabled) {
27
+ this.circuitBreaker = new CircuitBreaker({
28
+ failureThreshold: options.circuitBreakerThreshold || 5,
29
+ resetTimeout: options.circuitBreakerResetTimeout || 60000,
30
+ });
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Make HTTP request (internal, without retry/circuit breaker)
36
+ * @private
37
+ */
38
+ _requestInternal(method, path, data = null, options = {}) {
39
+ return new Promise((resolve, reject) => {
40
+ const url = new URL(path, this.baseUrl);
41
+ const isHttps = url.protocol === 'https:';
42
+ const client = isHttps ? https : http;
43
+
44
+ const requestOptions = {
45
+ hostname: url.hostname,
46
+ port: url.port || (isHttps ? 443 : 80),
47
+ path: url.pathname + url.search,
48
+ method,
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ ...options.headers,
52
+ },
53
+ timeout: options.timeout || this.timeout,
54
+ };
55
+
56
+ const req = client.request(requestOptions, (res) => {
57
+ let body = '';
58
+
59
+ res.on('data', (chunk) => {
60
+ body += chunk;
61
+ });
62
+
63
+ res.on('end', () => {
64
+ try {
65
+ const parsedBody = body ? JSON.parse(body) : {};
66
+ const response = {
67
+ statusCode: res.statusCode,
68
+ headers: res.headers,
69
+ data: parsedBody,
70
+ };
71
+
72
+ // Check if response indicates failure
73
+ if (res.statusCode >= 400) {
74
+ const error = new Error(`HTTP ${res.statusCode}: ${res.statusMessage || 'Request failed'}`);
75
+ error.statusCode = res.statusCode;
76
+ error.response = response;
77
+ reject(error);
78
+ } else {
79
+ resolve(response);
80
+ }
81
+ } catch (err) {
82
+ resolve({
83
+ statusCode: res.statusCode,
84
+ headers: res.headers,
85
+ data: body,
86
+ });
87
+ }
88
+ });
89
+ });
90
+
91
+ req.on('error', (err) => {
92
+ reject(err);
93
+ });
94
+
95
+ req.on('timeout', () => {
96
+ req.destroy();
97
+ reject(new Error('Request timeout'));
98
+ });
99
+
100
+ if (data) {
101
+ req.write(JSON.stringify(data));
102
+ }
103
+
104
+ req.end();
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Make HTTP request with retry and circuit breaker
110
+ * @private
111
+ */
112
+ async _request(method, path, data = null, options = {}) {
113
+ // Check circuit breaker
114
+ if (this.circuitBreakerEnabled && this.circuitBreaker) {
115
+ if (!this.circuitBreaker.canAttempt()) {
116
+ const error = new Error('Circuit breaker is OPEN - service unavailable');
117
+ error.circuitBreakerOpen = true;
118
+ error.circuitState = this.circuitBreaker.getState();
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ // Execute request with retry if enabled
124
+ const executeRequest = async () => {
125
+ try {
126
+ const result = await this._requestInternal(method, path, data, options);
127
+
128
+ // Record success in circuit breaker
129
+ if (this.circuitBreakerEnabled && this.circuitBreaker) {
130
+ this.circuitBreaker.recordSuccess();
131
+ }
132
+
133
+ return result;
134
+ } catch (error) {
135
+ // Record failure in circuit breaker
136
+ if (this.circuitBreakerEnabled && this.circuitBreaker) {
137
+ this.circuitBreaker.recordFailure();
138
+ }
139
+ throw error;
140
+ }
141
+ };
142
+
143
+ if (this.retryConfig.enabled) {
144
+ return retry(executeRequest, {
145
+ maxRetries: this.retryConfig.maxRetries,
146
+ baseDelay: this.retryConfig.baseDelay,
147
+ maxDelay: this.retryConfig.maxDelay,
148
+ shouldRetry: (error) => {
149
+ // Don't retry if circuit breaker is open
150
+ if (error.circuitBreakerOpen) {
151
+ return false;
152
+ }
153
+ // Retry on network errors or 5xx/429 status codes
154
+ if (error.statusCode) {
155
+ return shouldRetryHttpStatus(error.statusCode);
156
+ }
157
+ // Retry on network/timeout errors
158
+ return true;
159
+ },
160
+ });
161
+ }
162
+
163
+ return executeRequest();
164
+ }
165
+
166
+ /**
167
+ * GET request
168
+ * @param {string} path - Request path
169
+ * @param {Object} options - Request options
170
+ */
171
+ async get(path, options = {}) {
172
+ return this._request('GET', path, null, options);
173
+ }
174
+
175
+ /**
176
+ * POST request
177
+ * @param {string} path - Request path
178
+ * @param {Object} data - Request body data
179
+ * @param {Object} options - Request options
180
+ */
181
+ async post(path, data, options = {}) {
182
+ return this._request('POST', path, data, options);
183
+ }
184
+
185
+ /**
186
+ * PUT request
187
+ * @param {string} path - Request path
188
+ * @param {Object} data - Request body data
189
+ * @param {Object} options - Request options
190
+ */
191
+ async put(path, data, options = {}) {
192
+ return this._request('PUT', path, data, options);
193
+ }
194
+
195
+ /**
196
+ * DELETE request
197
+ * @param {string} path - Request path
198
+ * @param {Object} options - Request options
199
+ */
200
+ async delete(path, options = {}) {
201
+ return this._request('DELETE', path, null, options);
202
+ }
203
+
204
+ /**
205
+ * PATCH request
206
+ * @param {string} path - Request path
207
+ * @param {Object} data - Request body data
208
+ * @param {Object} options - Request options
209
+ */
210
+ async patch(path, data, options = {}) {
211
+ return this._request('PATCH', path, data, options);
212
+ }
213
+
214
+ /**
215
+ * Get circuit breaker state
216
+ * @returns {Object} - Circuit breaker state information
217
+ */
218
+ getCircuitBreakerState() {
219
+ if (!this.circuitBreakerEnabled || !this.circuitBreaker) {
220
+ return null;
221
+ }
222
+ return this.circuitBreaker.getState();
223
+ }
224
+
225
+ /**
226
+ * Reset circuit breaker
227
+ */
228
+ resetCircuitBreaker() {
229
+ if (this.circuitBreakerEnabled && this.circuitBreaker) {
230
+ this.circuitBreaker.reset();
231
+ }
232
+ }
233
+ }
234
+
105
235
  module.exports = ServiceClient;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Service Configuration Manager
3
+ * v2: Config-based service management for microservices
4
+ */
5
+
6
+ class ServiceConfig {
7
+ constructor(config = {}) {
8
+ this.services = config.services || {};
9
+ this.defaultOptions = config.defaultOptions || {
10
+ timeout: 5000,
11
+ retry: {
12
+ maxRetries: 3,
13
+ baseDelay: 1000,
14
+ maxDelay: 30000,
15
+ },
16
+ circuitBreaker: {
17
+ failureThreshold: 5,
18
+ resetTimeout: 60000,
19
+ },
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Register a service
25
+ * @param {string} name - Service name
26
+ * @param {string} baseUrl - Service base URL
27
+ * @param {Object} options - Service-specific options
28
+ */
29
+ register(name, baseUrl, options = {}) {
30
+ this.services[name] = {
31
+ baseUrl,
32
+ ...this.defaultOptions,
33
+ ...options,
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Get service configuration
39
+ * @param {string} name - Service name
40
+ * @returns {Object|null} - Service configuration or null if not found
41
+ */
42
+ get(name) {
43
+ return this.services[name] || null;
44
+ }
45
+
46
+ /**
47
+ * Get all registered services
48
+ * @returns {Object} - All service configurations
49
+ */
50
+ getAll() {
51
+ return { ...this.services };
52
+ }
53
+
54
+ /**
55
+ * Remove a service
56
+ * @param {string} name - Service name
57
+ */
58
+ unregister(name) {
59
+ delete this.services[name];
60
+ }
61
+
62
+ /**
63
+ * Load services from configuration object
64
+ * @param {Object} config - Configuration object with services
65
+ */
66
+ load(config) {
67
+ if (config.services) {
68
+ this.services = { ...this.services, ...config.services };
69
+ }
70
+ if (config.defaultOptions) {
71
+ this.defaultOptions = { ...this.defaultOptions, ...config.defaultOptions };
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Export current configuration
77
+ * @returns {Object} - Current configuration
78
+ */
79
+ export() {
80
+ return {
81
+ services: this.services,
82
+ defaultOptions: this.defaultOptions,
83
+ };
84
+ }
85
+ }
86
+
87
+ module.exports = ServiceConfig;
88
+