elasticio-sailor-nodejs 3.0.0-dev7.1 → 3.0.0-sailor-proxy-dev2
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/.eslintrc.js +12 -146
- package/.nsprc +0 -8
- package/CHANGELOG.md +0 -10
- package/config/local.json +19 -0
- package/lib/executor.js +0 -9
- package/lib/proxy-client.js +757 -0
- package/lib/sailor.js +74 -204
- package/lib/settings.js +17 -20
- package/lib/utils.js +8 -0
- package/package.json +8 -6
- package/run.js +33 -37
- package/run.local.js +16 -0
- package/tsconfig.json +23 -0
- package/lib/amqp.js +0 -647
- package/lib/messagesDB.js +0 -37
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
const log = require('./logging.js');
|
|
2
|
+
const Encryptor = require('./encryptor.js');
|
|
3
|
+
const _ = require('lodash');
|
|
4
|
+
const eventToPromise = require('event-to-promise');
|
|
5
|
+
const uuid = require('uuid');
|
|
6
|
+
const http2 = require('http2');
|
|
7
|
+
const { getJitteredDelay } = require('./utils.js');
|
|
8
|
+
const { Promise } = require('q');
|
|
9
|
+
const pThrottle = require('p-throttle');
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
HTTP2_HEADER_PATH,
|
|
13
|
+
HTTP2_HEADER_METHOD,
|
|
14
|
+
HTTP2_HEADER_AUTHORIZATION,
|
|
15
|
+
HTTP2_HEADER_STATUS,
|
|
16
|
+
NGHTTP2_NO_ERROR
|
|
17
|
+
} = http2.constants;
|
|
18
|
+
|
|
19
|
+
const HEADER_ROUTING_KEY = 'x-eio-routing-key';
|
|
20
|
+
const AMQP_HEADER_META_PREFIX = 'x-eio-meta-';
|
|
21
|
+
const OBJECT_ID_HEADER = 'x-ipaas-object-storage-id';
|
|
22
|
+
const PROXY_FORWARD_HEADER_PREFIX = 'x-sailor-proxy-forward-';
|
|
23
|
+
const MESSAGE_PROCESSING_STATUS = {
|
|
24
|
+
SUCCESS: 'success',
|
|
25
|
+
ERROR: 'error'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
class ProxyClient {
|
|
29
|
+
constructor(settings) {
|
|
30
|
+
this.settings = settings;
|
|
31
|
+
this._encryptor = new Encryptor(this.settings.MESSAGE_CRYPTO_PASSWORD, this.settings.MESSAGE_CRYPTO_IV);
|
|
32
|
+
this.closed = true;
|
|
33
|
+
this.listeningForMessages = true;
|
|
34
|
+
this.clientSession = null;
|
|
35
|
+
this.reconnecting = false;
|
|
36
|
+
this.reconnectAttempts = 0;
|
|
37
|
+
this.reconnectTimer = null;
|
|
38
|
+
this.getMessageStreams = new Set();
|
|
39
|
+
|
|
40
|
+
const username = settings.API_USERNAME;
|
|
41
|
+
const password = settings.API_KEY;
|
|
42
|
+
if (!username || !password) {
|
|
43
|
+
throw new Error('API_USERNAME and API_KEY must be set to connect to Sailor Proxy');
|
|
44
|
+
}
|
|
45
|
+
this.authHeader = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
|
|
46
|
+
|
|
47
|
+
this.throttles = {
|
|
48
|
+
// 100 Messages per Second
|
|
49
|
+
data: pThrottle(() => Promise.resolve(true),
|
|
50
|
+
settings.DATA_RATE_LIMIT,
|
|
51
|
+
settings.RATE_INTERVAL),
|
|
52
|
+
error: pThrottle(() => Promise.resolve(true),
|
|
53
|
+
settings.ERROR_RATE_LIMIT,
|
|
54
|
+
settings.RATE_INTERVAL),
|
|
55
|
+
snapshot: pThrottle(() => Promise.resolve(true),
|
|
56
|
+
settings.SNAPSHOT_RATE_LIMIT,
|
|
57
|
+
settings.RATE_INTERVAL)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Health check method for K8s
|
|
62
|
+
// Returns true during reconnection attempts to prevent pod restarts during transient issues
|
|
63
|
+
// Only returns false if:
|
|
64
|
+
// - Connection was intentionally closed (this.closed = true)
|
|
65
|
+
// - Max reconnection attempts exhausted (reconnecting = false, closed = true)
|
|
66
|
+
isConnected() {
|
|
67
|
+
// For K8s health checks: consider the client "connected" if we're actively trying to reconnect
|
|
68
|
+
// This prevents pod restarts during transient network issues
|
|
69
|
+
if (this.reconnecting && !this.closed) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return !this.closed && this.clientSession && !this.clientSession.destroyed;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async connect() {
|
|
76
|
+
try {
|
|
77
|
+
log.trace('connecting to http2 server');
|
|
78
|
+
this.clientSession = http2.connect(this.settings.SAILOR_PROXY_URI);
|
|
79
|
+
this.closed = false;
|
|
80
|
+
this.reconnectAttempts = 0;
|
|
81
|
+
|
|
82
|
+
// Set up event listeners for connection management
|
|
83
|
+
this._setupConnectionListeners();
|
|
84
|
+
|
|
85
|
+
await eventToPromise(this.clientSession, 'connect');
|
|
86
|
+
log.info('Successfully connected to Sailor Proxy');
|
|
87
|
+
} catch (err) {
|
|
88
|
+
log.error({ err }, 'Failed to connect to Sailor Proxy');
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_setupConnectionListeners() {
|
|
94
|
+
if (!this.clientSession) return;
|
|
95
|
+
|
|
96
|
+
// Handle connection errors
|
|
97
|
+
this.clientSession.on('error', (err) => {
|
|
98
|
+
log.error({ err, closed: this.closed }, 'HTTP2 session error');
|
|
99
|
+
if (!this.closed) {
|
|
100
|
+
this._handleDisconnection('error', err);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Handle connection close
|
|
105
|
+
this.clientSession.on('close', () => {
|
|
106
|
+
log.debug({ closed: this.closed, reconnecting: this.reconnecting }, 'HTTP2 session closed');
|
|
107
|
+
if (!this.closed && !this.reconnecting) {
|
|
108
|
+
log.warn('HTTP2 session closed unexpectedly, initiating reconnection');
|
|
109
|
+
this._handleDisconnection('close');
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Handle GOAWAY frames (server-initiated shutdown)
|
|
114
|
+
this.clientSession.on('goaway', (errorCode, lastStreamID, opaqueData) => {
|
|
115
|
+
log.debug({ errorCode, lastStreamID, closed: this.closed }, 'Received GOAWAY from server');
|
|
116
|
+
if (!this.closed) {
|
|
117
|
+
log.warn('Received GOAWAY frame from server, initiating reconnection');
|
|
118
|
+
this._handleDisconnection('goaway', { errorCode, lastStreamID });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Handle timeout
|
|
123
|
+
this.clientSession.on('timeout', () => {
|
|
124
|
+
log.debug({ closed: this.closed }, 'HTTP2 session timeout');
|
|
125
|
+
if (!this.closed) {
|
|
126
|
+
log.warn('HTTP2 session timeout, initiating reconnection');
|
|
127
|
+
this._handleDisconnection('timeout');
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_handleDisconnection(reason, details) {
|
|
133
|
+
if (this.reconnecting || this.closed) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
log.warn({ reason, details }, 'Connection lost, initiating reconnection');
|
|
138
|
+
this.reconnecting = true;
|
|
139
|
+
|
|
140
|
+
// Clean up the current session
|
|
141
|
+
if (this.clientSession && !this.clientSession.destroyed) {
|
|
142
|
+
this.clientSession.destroy();
|
|
143
|
+
}
|
|
144
|
+
this.clientSession = null;
|
|
145
|
+
|
|
146
|
+
this._scheduleReconnect();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_scheduleReconnect() {
|
|
150
|
+
if (this.closed) {
|
|
151
|
+
log.info('Connection closed intentionally, skipping reconnection');
|
|
152
|
+
this.reconnecting = false;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (this.reconnectAttempts >= this.settings.PROXY_RECONNECT_MAX_RETRIES) {
|
|
157
|
+
log.error({ attempts: this.reconnectAttempts }, 'Max reconnection attempts reached, giving up');
|
|
158
|
+
this.reconnecting = false;
|
|
159
|
+
this.closed = true; // Mark as closed so K8s health check will fail and restart the pod
|
|
160
|
+
// Optionally emit an event or call a callback here
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.reconnectAttempts++;
|
|
165
|
+
const baseDelay = Math.min(
|
|
166
|
+
this.settings.PROXY_RECONNECT_INITIAL_DELAY *
|
|
167
|
+
Math.pow(this.settings.PROXY_RECONNECT_BACKOFF_MULTIPLIER, this.reconnectAttempts - 1),
|
|
168
|
+
this.settings.PROXY_RECONNECT_MAX_DELAY
|
|
169
|
+
);
|
|
170
|
+
// Apply jitter to avoid thundering herd problem
|
|
171
|
+
const delay = getJitteredDelay(baseDelay, this.settings.PROXY_RECONNECT_JITTER_FACTOR);
|
|
172
|
+
|
|
173
|
+
log.info({ attempt: this.reconnectAttempts, baseDelayMs: baseDelay, jitteredDelayMs: delay }, 'Scheduling reconnection attempt');
|
|
174
|
+
|
|
175
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
176
|
+
try {
|
|
177
|
+
log.info({ attempt: this.reconnectAttempts }, 'Attempting to reconnect');
|
|
178
|
+
await this._reconnect();
|
|
179
|
+
log.info('Successfully reconnected to Sailor Proxy');
|
|
180
|
+
this.reconnecting = false;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
log.error({ attempt: this.reconnectAttempts, error: err }, 'Reconnection attempt failed');
|
|
183
|
+
this._scheduleReconnect();
|
|
184
|
+
}
|
|
185
|
+
}, delay);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async _reconnect() {
|
|
189
|
+
this.clientSession = http2.connect(this.settings.SAILOR_PROXY_URI);
|
|
190
|
+
this._setupConnectionListeners();
|
|
191
|
+
await eventToPromise(this.clientSession, 'connect');
|
|
192
|
+
this.reconnectAttempts = 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async disconnect() {
|
|
196
|
+
this.closed = true;
|
|
197
|
+
this.reconnecting = false;
|
|
198
|
+
|
|
199
|
+
// Clear any pending reconnection timers
|
|
200
|
+
if (this.reconnectTimer) {
|
|
201
|
+
log.debug('Clearing pending reconnection timer');
|
|
202
|
+
clearTimeout(this.reconnectTimer);
|
|
203
|
+
this.reconnectTimer = null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return new Promise((resolve) => {
|
|
207
|
+
log.debug('Disconnecting from Sailor Proxy');
|
|
208
|
+
if (!this.clientSession || this.clientSession.destroyed) {
|
|
209
|
+
log.debug('Session already destroyed');
|
|
210
|
+
return resolve();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// TODO: what if incoming message is received during graceful shutdown?
|
|
214
|
+
this.clientSession.close(() => {
|
|
215
|
+
log.debug('Successfully closed HTTP2 connection');
|
|
216
|
+
resolve();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async _ensureConnection() {
|
|
222
|
+
if (this.isConnected()) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (this.reconnecting) {
|
|
227
|
+
// Wait for reconnection to complete
|
|
228
|
+
log.info('Waiting for reconnection to complete');
|
|
229
|
+
const maxWait = 30000; // 30 seconds
|
|
230
|
+
const startTime = Date.now();
|
|
231
|
+
while (this.reconnecting && (Date.now() - startTime) < maxWait) {
|
|
232
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!this.isConnected()) {
|
|
236
|
+
throw new Error('Failed to reconnect within timeout period');
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (this.closed) {
|
|
242
|
+
throw new Error('Connection is closed. Call connect() first.');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// If we get here, connection was lost but reconnection hasn't started
|
|
246
|
+
throw new Error('Connection lost and no reconnection in progress');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async _proxyRequestWithRetries(operationName, requestFn) {
|
|
250
|
+
const maxRetries = this.settings.PROXY_OBJECT_REQUEST_RETRY_ATTEMPTS;
|
|
251
|
+
const retryDelay = this.settings.PROXY_OBJECT_REQUEST_RETRY_DELAY;
|
|
252
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
253
|
+
try {
|
|
254
|
+
const result = await requestFn();
|
|
255
|
+
if (attempt > 0) {
|
|
256
|
+
log.info({ attempt, maxRetries }, `${operationName} succeeded after retry`);
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const isLastAttempt = attempt === maxRetries;
|
|
261
|
+
const isRetryable = error.isNetworkError ||
|
|
262
|
+
(error.statusCode && error.statusCode >= 500) ||
|
|
263
|
+
error.code === 'ECONNRESET' ||
|
|
264
|
+
error.code === 'ETIMEDOUT';
|
|
265
|
+
|
|
266
|
+
if (!isRetryable || isLastAttempt) {
|
|
267
|
+
log.error({
|
|
268
|
+
attempt,
|
|
269
|
+
maxRetries,
|
|
270
|
+
error: error.message,
|
|
271
|
+
isRetryable
|
|
272
|
+
}, `${operationName} failed, no more retries`);
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const delay = Math.min(
|
|
277
|
+
retryDelay * Math.pow(2, attempt),
|
|
278
|
+
this.settings.PROXY_OBJECT_REQUEST_MAX_RETRY_DELAY
|
|
279
|
+
);
|
|
280
|
+
log.warn({
|
|
281
|
+
attempt,
|
|
282
|
+
maxRetries,
|
|
283
|
+
error: error.message,
|
|
284
|
+
nextRetryIn: delay
|
|
285
|
+
}, `${operationName} failed, retrying...`);
|
|
286
|
+
|
|
287
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
288
|
+
|
|
289
|
+
if (!this.isConnected()) {
|
|
290
|
+
log.info('Reconnecting before retry...');
|
|
291
|
+
await this.connect();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async fetchMessageBody(message, logger) {
|
|
298
|
+
await this._ensureConnection();
|
|
299
|
+
|
|
300
|
+
const { body, headers } = message;
|
|
301
|
+
|
|
302
|
+
logger.info('Checking if incoming messages is lightweight...');
|
|
303
|
+
|
|
304
|
+
if (!headers) {
|
|
305
|
+
logger.info('Empty headers so not lightweight.');
|
|
306
|
+
return body;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const { [OBJECT_ID_HEADER]: objectId } = headers;
|
|
310
|
+
|
|
311
|
+
if (!objectId) {
|
|
312
|
+
logger.trace('No object id header so not lightweight.');
|
|
313
|
+
return body;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
logger.info('Object id header found, message is lightweight.', { objectId });
|
|
317
|
+
|
|
318
|
+
logger.info('Going to fetch message body.', { objectId });
|
|
319
|
+
|
|
320
|
+
await this._proxyRequestWithRetries('Fetch message body', () => new Promise((resolve, reject) => {
|
|
321
|
+
const getObjectStream = this.clientSession.request({
|
|
322
|
+
[HTTP2_HEADER_PATH]: `/object/${objectId}`,
|
|
323
|
+
[HTTP2_HEADER_METHOD]: 'GET',
|
|
324
|
+
[HTTP2_HEADER_AUTHORIZATION]: this.authHeader
|
|
325
|
+
}).pipe(this._encryptor.createDecipher());
|
|
326
|
+
|
|
327
|
+
const chunks = [];
|
|
328
|
+
getObjectStream.on('data', chunk => {
|
|
329
|
+
chunks.push(chunk);
|
|
330
|
+
});
|
|
331
|
+
getObjectStream.on('error', (err) => {
|
|
332
|
+
logger.error(err, 'Error during fetching message body');
|
|
333
|
+
reject(err);
|
|
334
|
+
});
|
|
335
|
+
getObjectStream.on('end', () => {
|
|
336
|
+
logger.info('Message stream ended by server');
|
|
337
|
+
const buffer = Buffer.concat(chunks);
|
|
338
|
+
logger.info({ messageSize: buffer.length }, 'Received complete message from server');
|
|
339
|
+
resolve({ data: JSON.parse(buffer.toString()) });
|
|
340
|
+
});
|
|
341
|
+
}));
|
|
342
|
+
|
|
343
|
+
logger.info('Successfully obtained message body.', { objectId });
|
|
344
|
+
logger.trace('Message body object received');
|
|
345
|
+
|
|
346
|
+
return objectId;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async uploadMessageBody(bodyBuf) {
|
|
350
|
+
await this._ensureConnection();
|
|
351
|
+
|
|
352
|
+
return this._proxyRequestWithRetries('Upload message body', () => new Promise((resolve, reject) => {
|
|
353
|
+
const postMessageStream = this.clientSession.request({
|
|
354
|
+
[HTTP2_HEADER_PATH]: '/object',
|
|
355
|
+
[HTTP2_HEADER_METHOD]: 'POST',
|
|
356
|
+
[HTTP2_HEADER_AUTHORIZATION]: this.authHeader
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
let responseData = '';
|
|
360
|
+
let statusCode = null;
|
|
361
|
+
|
|
362
|
+
postMessageStream.on('response', (headers, flags) => {
|
|
363
|
+
statusCode = headers[http2.constants.HTTP2_HEADER_STATUS];
|
|
364
|
+
if (statusCode !== 200) {
|
|
365
|
+
const error = new Error(`Failed to upload message body, status code: ${statusCode}`);
|
|
366
|
+
error.statusCode = statusCode;
|
|
367
|
+
return reject(error);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
postMessageStream.on('data', chunk => {
|
|
372
|
+
responseData += chunk;
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
postMessageStream.on('error', (err) => {
|
|
376
|
+
log.error(err, 'Error during upload message body');
|
|
377
|
+
err.isNetworkError = true;
|
|
378
|
+
reject(err);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
postMessageStream.on('end', () => {
|
|
382
|
+
if (!responseData) {
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
const responseJson = JSON.parse(responseData);
|
|
387
|
+
resolve(responseJson.objectId);
|
|
388
|
+
} catch (e) {
|
|
389
|
+
log.error(e, 'Failed to parse upload message body response');
|
|
390
|
+
reject(e);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const cipher = this._encryptor.createCipher();
|
|
395
|
+
cipher.pipe(postMessageStream);
|
|
396
|
+
cipher.write(bodyBuf);
|
|
397
|
+
cipher.end();
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async listenForMessages(messageHandler) {
|
|
402
|
+
while (this.listeningForMessages) {
|
|
403
|
+
try {
|
|
404
|
+
log.debug('Starting to listen for messages from proxy');
|
|
405
|
+
await this._ensureConnection();
|
|
406
|
+
|
|
407
|
+
const stepId = this.settings.STEP_ID;
|
|
408
|
+
const prefetch = this.settings.PROXY_PREFETCH_SAILOR;
|
|
409
|
+
// TODO: When prefetch > 1, what if one message takes a long time to process - do we want to wait for it before requesting the next one?
|
|
410
|
+
await Promise.all(new Array(prefetch).fill().map(async () => {
|
|
411
|
+
const queryParams = new URLSearchParams({
|
|
412
|
+
stepId,
|
|
413
|
+
prefetch
|
|
414
|
+
}).toString();
|
|
415
|
+
log.info({ stepId, prefetch }, 'Requesting message from proxy');
|
|
416
|
+
// TODO: what timeout is here? e.g. when flow is realtime - we might need to wait a long time...
|
|
417
|
+
const getMessageStream = this.clientSession.request({
|
|
418
|
+
[HTTP2_HEADER_PATH]: `/message?${queryParams}`,
|
|
419
|
+
[HTTP2_HEADER_METHOD]: 'GET',
|
|
420
|
+
[HTTP2_HEADER_AUTHORIZATION]: this.authHeader
|
|
421
|
+
});
|
|
422
|
+
this.getMessageStreams.add(getMessageStream);
|
|
423
|
+
|
|
424
|
+
const { headers, body } = await new Promise((resolve, reject) => {
|
|
425
|
+
getMessageStream.on('response', (headers, flags) => {
|
|
426
|
+
log.info({ headers, flags }, 'Connected to message stream');
|
|
427
|
+
if (headers[HTTP2_HEADER_STATUS] !== 200) {
|
|
428
|
+
return reject(new Error(`Failed to get message, status code: ${headers[HTTP2_HEADER_STATUS]}`));
|
|
429
|
+
}
|
|
430
|
+
const messageId = headers['x-message-id'];
|
|
431
|
+
const chunks = [];
|
|
432
|
+
getMessageStream.on('data', chunk => {
|
|
433
|
+
chunks.push(chunk);
|
|
434
|
+
});
|
|
435
|
+
getMessageStream.on('end', () => {
|
|
436
|
+
log.info('Message stream ended by server');
|
|
437
|
+
const body = Buffer.concat(chunks);
|
|
438
|
+
log.info({
|
|
439
|
+
messageId,
|
|
440
|
+
messageSize: body.length
|
|
441
|
+
}, 'Received complete message from server');
|
|
442
|
+
log.trace({ body: body.toString() }, 'Message body as string');
|
|
443
|
+
resolve({ headers, body });
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
getMessageStream.on('close', () => {
|
|
448
|
+
log.warn('Message stream closed by server');
|
|
449
|
+
reject(new Error('Message stream closed by server'));
|
|
450
|
+
});
|
|
451
|
+
getMessageStream.on('error', (err) => {
|
|
452
|
+
log.error(err, 'Error on message stream');
|
|
453
|
+
reject(err);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const proxyHeaders = this._extractProxyHeaders(headers);
|
|
458
|
+
const message = this._decodeMessage(body, headers);
|
|
459
|
+
log.debug({ proxyHeaders, message }, 'Processing received message');
|
|
460
|
+
await messageHandler(proxyHeaders, message);
|
|
461
|
+
}));
|
|
462
|
+
} catch (err) {
|
|
463
|
+
if (this.closed) {
|
|
464
|
+
log.info('Connection closed, stopping message listener');
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (!this.reconnecting) {
|
|
469
|
+
log.error(err, 'Error in listenForMessages, waiting for reconnection');
|
|
470
|
+
} else {
|
|
471
|
+
log.debug('Currently reconnecting, will retry listening for messages after reconnection');
|
|
472
|
+
}
|
|
473
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
474
|
+
} finally {
|
|
475
|
+
log.debug('Cleaning up message streams');
|
|
476
|
+
this.getMessageStreams.clear();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async stopListeningForMessages() {
|
|
482
|
+
log.info('Stopping listening for messages');
|
|
483
|
+
await Promise.all(Array.from(this.getMessageStreams).map(stream => {
|
|
484
|
+
if (stream.closed || stream.destroyed) {
|
|
485
|
+
log.debug({ closed: stream.closed, destroyed: stream.destroyed }, 'Message stream is already closed or destroyed');
|
|
486
|
+
return Promise.resolve();
|
|
487
|
+
}
|
|
488
|
+
return new Promise((resolve) => {
|
|
489
|
+
stream.close(NGHTTP2_NO_ERROR, () => {
|
|
490
|
+
log.debug('Closed message stream');
|
|
491
|
+
resolve();
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
}));
|
|
495
|
+
this.listeningForMessages = false;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async sendMessage({
|
|
499
|
+
incomingMessageId,
|
|
500
|
+
type,
|
|
501
|
+
data,
|
|
502
|
+
headers
|
|
503
|
+
}) {
|
|
504
|
+
await this._ensureConnection();
|
|
505
|
+
|
|
506
|
+
const throttledSend = this.throttles[type];
|
|
507
|
+
if (throttledSend) {
|
|
508
|
+
log.debug({ incomingMessageId, type, headers }, 'Applying rate limiting for message send');
|
|
509
|
+
await throttledSend();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
log.debug({ incomingMessageId, type, headers }, 'Sending message to proxy');
|
|
513
|
+
log.trace({ data }, 'Message data to send to proxy');
|
|
514
|
+
const proxyHeaders = this._createProxyHeaders(headers);
|
|
515
|
+
const encryptedData = this.encryptMessageContent(data, headers.protocolVersion);
|
|
516
|
+
if (encryptedData.length > this.settings.OUTGOING_MESSAGE_SIZE_LIMIT) {
|
|
517
|
+
const error = new Error(`Outgoing message size ${encryptedData.length}`
|
|
518
|
+
+ ` exceeds limit of ${this.settings.OUTGOING_MESSAGE_SIZE_LIMIT}.`);
|
|
519
|
+
log.error(error);
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const messageHeaders = _.mapKeys(data.headers || {}, (value, key) => key.toLowerCase());
|
|
524
|
+
const customRoutingKey = messageHeaders[HEADER_ROUTING_KEY];
|
|
525
|
+
const queryParams = new URLSearchParams({
|
|
526
|
+
incomingMessageId,
|
|
527
|
+
stepId: this.settings.STEP_ID,
|
|
528
|
+
type,
|
|
529
|
+
...(customRoutingKey ? { customRoutingKey } : {})
|
|
530
|
+
}).toString();
|
|
531
|
+
|
|
532
|
+
await this._proxyRequestWithRetries('Send message', () => new Promise((resolve, reject) => {
|
|
533
|
+
const postMessageStream = this.clientSession.request({
|
|
534
|
+
...proxyHeaders,
|
|
535
|
+
[HTTP2_HEADER_PATH]: `/message?${queryParams}`,
|
|
536
|
+
[HTTP2_HEADER_METHOD]: 'POST',
|
|
537
|
+
[HTTP2_HEADER_AUTHORIZATION]: this.authHeader
|
|
538
|
+
});
|
|
539
|
+
postMessageStream.write(encryptedData);
|
|
540
|
+
postMessageStream.end();
|
|
541
|
+
|
|
542
|
+
postMessageStream.on('response', (headers) => {
|
|
543
|
+
log.debug({ status: headers[HTTP2_HEADER_STATUS] }, 'Send message response');
|
|
544
|
+
if (headers[HTTP2_HEADER_STATUS] !== 200) {
|
|
545
|
+
log.error({ headers }, 'Failed to send message');
|
|
546
|
+
const error = new Error(`Failed to send message, status code: ${headers[HTTP2_HEADER_STATUS]}`);
|
|
547
|
+
error.statusCode = headers[HTTP2_HEADER_STATUS];
|
|
548
|
+
return reject(error);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
postMessageStream.on('error', (err) => {
|
|
552
|
+
log.error(err, 'Error during sending message');
|
|
553
|
+
err.isNetworkError = true;
|
|
554
|
+
reject(err);
|
|
555
|
+
});
|
|
556
|
+
postMessageStream.on('end', () => {
|
|
557
|
+
log.debug('Send message end event');
|
|
558
|
+
resolve();
|
|
559
|
+
});
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
_decodeMessage(originalMessage, headers) {
|
|
564
|
+
log.trace('Message received');
|
|
565
|
+
let message;
|
|
566
|
+
if (this.settings.INPUT_FORMAT === 'error') {
|
|
567
|
+
message = this._decodeErrorMessage(originalMessage, headers);
|
|
568
|
+
} else {
|
|
569
|
+
message = this._decodeDefaultMessage(originalMessage, headers);
|
|
570
|
+
}
|
|
571
|
+
message.headers = message.headers || {};
|
|
572
|
+
if (headers.replyTo) {
|
|
573
|
+
message.headers.reply_to = headers.replyTo;
|
|
574
|
+
}
|
|
575
|
+
return message;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
_decodeDefaultMessage(originalMessage, headers) {
|
|
579
|
+
const protocolVersion = Number(headers.protocolVersion || 1);
|
|
580
|
+
return this._encryptor.decryptMessageContent(
|
|
581
|
+
originalMessage,
|
|
582
|
+
protocolVersion < 2 ? 'base64' : undefined
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
_decodeErrorMessage(originalMessage, headers) {
|
|
587
|
+
const errorBody = JSON.parse(originalMessage.toString());
|
|
588
|
+
if (errorBody.error) {
|
|
589
|
+
errorBody.error = this._encryptor.decryptMessageContent(Buffer.from(errorBody.error), 'base64');
|
|
590
|
+
}
|
|
591
|
+
if (errorBody.errorInput) {
|
|
592
|
+
errorBody.errorInput = this._encryptor.decryptMessageContent(errorBody.errorInput, 'base64');
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
body: errorBody,
|
|
596
|
+
headers
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async finishProcessing(incomingHeaders, status) {
|
|
601
|
+
await this._ensureConnection();
|
|
602
|
+
|
|
603
|
+
if (Object.values(MESSAGE_PROCESSING_STATUS).indexOf(status) === -1) {
|
|
604
|
+
throw new Error(`Invalid message processing status: ${status}`);
|
|
605
|
+
}
|
|
606
|
+
const incomingMessageId = incomingHeaders.messageId;
|
|
607
|
+
log.debug({ incomingMessageId, status }, 'Finishing processing of message');
|
|
608
|
+
const queryParams = new URLSearchParams({
|
|
609
|
+
incomingMessageId,
|
|
610
|
+
status
|
|
611
|
+
}).toString();
|
|
612
|
+
|
|
613
|
+
await this._proxyRequestWithRetries('Finish processing', () => new Promise((resolve, reject) => {
|
|
614
|
+
const postMessageStream = this.clientSession.request({
|
|
615
|
+
[HTTP2_HEADER_PATH]: `/finish-processing?${queryParams}`,
|
|
616
|
+
[HTTP2_HEADER_METHOD]: 'POST',
|
|
617
|
+
[HTTP2_HEADER_AUTHORIZATION]: this.authHeader
|
|
618
|
+
});
|
|
619
|
+
postMessageStream.end();
|
|
620
|
+
|
|
621
|
+
postMessageStream.on('response', (headers) => {
|
|
622
|
+
log.debug({ status: headers[HTTP2_HEADER_STATUS] }, 'Finish processing response event');
|
|
623
|
+
if (headers[HTTP2_HEADER_STATUS] !== 200) {
|
|
624
|
+
log.error({ headers }, 'Failed to finish processing message');
|
|
625
|
+
const error = new Error(`Failed to finish processing message, status code: ${headers[HTTP2_HEADER_STATUS]}`);
|
|
626
|
+
error.statusCode = headers[HTTP2_HEADER_STATUS];
|
|
627
|
+
return reject(error);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
postMessageStream.on('end', () => {
|
|
631
|
+
log.debug('Finish processing end event');
|
|
632
|
+
resolve();
|
|
633
|
+
});
|
|
634
|
+
postMessageStream.on('error', (err) => {
|
|
635
|
+
err.isNetworkError = true;
|
|
636
|
+
reject(err);
|
|
637
|
+
});
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
encryptMessageContent(body, protocolVersion = 1) {
|
|
642
|
+
return this._encryptor.encryptMessageContent(
|
|
643
|
+
body,
|
|
644
|
+
protocolVersion < 2
|
|
645
|
+
? 'base64'
|
|
646
|
+
: undefined
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async sendError(err, headers, originalMessage, incomingHeaders) {
|
|
651
|
+
await this._ensureConnection();
|
|
652
|
+
|
|
653
|
+
const settings = this.settings;
|
|
654
|
+
|
|
655
|
+
const encryptedError = this._encryptor.encryptMessageContent({
|
|
656
|
+
name: err.name,
|
|
657
|
+
message: err.message,
|
|
658
|
+
stack: err.stack
|
|
659
|
+
}, 'base64').toString();
|
|
660
|
+
|
|
661
|
+
const payload = {
|
|
662
|
+
error: encryptedError
|
|
663
|
+
};
|
|
664
|
+
if (originalMessage) {
|
|
665
|
+
const protocolVersion = Number(incomingHeaders.protocolVersion || 1);
|
|
666
|
+
if (protocolVersion >= 2) {
|
|
667
|
+
payload.errorInput = this._encryptor.encryptMessageContent(
|
|
668
|
+
originalMessage,
|
|
669
|
+
'base64'
|
|
670
|
+
).toString();
|
|
671
|
+
} else {
|
|
672
|
+
payload.errorInput = originalMessage;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
const errorPayload = JSON.stringify(payload);
|
|
676
|
+
|
|
677
|
+
let result = await this.sendMessage({
|
|
678
|
+
incomingMessageId: incomingHeaders ? incomingHeaders.messageId : undefined,
|
|
679
|
+
type: 'error',
|
|
680
|
+
data: errorPayload,
|
|
681
|
+
headers
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
return result;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async sendRebound(reboundError, incomingHeaders, outgoingHeaders) {
|
|
688
|
+
await this._ensureConnection();
|
|
689
|
+
|
|
690
|
+
outgoingHeaders.end = new Date().getTime();
|
|
691
|
+
outgoingHeaders.reboundReason = reboundError.message;
|
|
692
|
+
return this.sendMessage({
|
|
693
|
+
type: 'rebound',
|
|
694
|
+
headers: outgoingHeaders,
|
|
695
|
+
incomingMessageId: incomingHeaders ? incomingHeaders.messageId : undefined,
|
|
696
|
+
data: reboundError
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async sendSnapshot(data, headers) {
|
|
701
|
+
await this._ensureConnection();
|
|
702
|
+
|
|
703
|
+
const payload = JSON.stringify(data);
|
|
704
|
+
const properties = this._createProxyHeaders(headers);
|
|
705
|
+
return this.sendMessage({
|
|
706
|
+
type: 'snapshot',
|
|
707
|
+
data: payload,
|
|
708
|
+
headers: properties
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
_createProxyHeaders(headers) {
|
|
713
|
+
headers.messageId = headers.messageId || uuid.v4();
|
|
714
|
+
return Object.entries(headers || {}).reduce((acc, [key, value]) => {
|
|
715
|
+
acc[`${PROXY_FORWARD_HEADER_PREFIX}${_.kebabCase(key)}`] = value;
|
|
716
|
+
return acc;
|
|
717
|
+
}, {});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
_extractProxyHeaders(proxyHeaders) {
|
|
721
|
+
log.trace({ proxyHeaders }, 'Extracting proxy headers');
|
|
722
|
+
const headers = Object.entries(proxyHeaders || {}).reduce((acc, [key, value]) => {
|
|
723
|
+
if (key.startsWith(PROXY_FORWARD_HEADER_PREFIX)) {
|
|
724
|
+
const originalKey = key.substring(PROXY_FORWARD_HEADER_PREFIX.length);
|
|
725
|
+
acc[_.camelCase(originalKey)] = value;
|
|
726
|
+
}
|
|
727
|
+
return acc;
|
|
728
|
+
}, {});
|
|
729
|
+
|
|
730
|
+
const metaHeaderNames = Object.keys(headers)
|
|
731
|
+
.filter(key => key.toLowerCase().startsWith(AMQP_HEADER_META_PREFIX));
|
|
732
|
+
|
|
733
|
+
const metaHeaders = _.pick(headers, metaHeaderNames);
|
|
734
|
+
const metaHeadersLowerCased = _.mapKeys(metaHeaders, (value, key) => key.toLowerCase());
|
|
735
|
+
|
|
736
|
+
const result = {
|
|
737
|
+
stepId: headers.stepId,
|
|
738
|
+
...metaHeadersLowerCased,
|
|
739
|
+
threadId: headers.threadId || metaHeadersLowerCased['x-eio-meta-trace-id'],
|
|
740
|
+
messageId: headers.messageId,
|
|
741
|
+
parentMessageId: headers.parentMessageId
|
|
742
|
+
};
|
|
743
|
+
if (!result.threadId) {
|
|
744
|
+
const threadId = uuid.v4();
|
|
745
|
+
log.debug({ threadId }, 'Initiate new thread as it is not started ATM');
|
|
746
|
+
result.threadId = threadId;
|
|
747
|
+
}
|
|
748
|
+
if (headers.replyTo) {
|
|
749
|
+
result.replyTo = headers.replyTo;
|
|
750
|
+
}
|
|
751
|
+
log.debug({ result }, 'Extracted proxy headers');
|
|
752
|
+
return result;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
exports.ProxyClient = ProxyClient;
|
|
757
|
+
exports.MESSAGE_PROCESSING_STATUS = MESSAGE_PROCESSING_STATUS;
|