@rebound-dlq/node 0.2.3 → 0.2.5
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 +120 -64
- package/dist/adapters/base.adapter.js +1 -1
- package/dist/core/client.d.ts +4 -0
- package/dist/core/client.js +53 -10
- package/dist/core/constants.d.ts +1 -4
- package/dist/core/constants.js +2 -5
- package/dist/core/endpoint.d.ts +2 -0
- package/dist/core/endpoint.js +19 -0
- package/dist/core/queue.d.ts +24 -1
- package/dist/core/queue.js +315 -46
- package/dist/core/wrapper.js +3 -6
- package/dist/models/payload.d.ts +1 -1
- package/package.json +23 -18
package/README.md
CHANGED
|
@@ -1,106 +1,162 @@
|
|
|
1
|
-
# Rebound DLQ
|
|
1
|
+
# Rebound DLQ — Node.js SDK
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@rebound-dlq/node)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
SDK oficial em Node.js para integrar sua aplicação com a plataforma **[Rebound DLQ](https://rebound-dlq.com)**.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Capture falhas, registre eventos de erro e garanta rastreabilidade das suas filas mortas — tudo sem impactar a performance da sua aplicação.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
---
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
- 🛡️ **Segurança e Criptografia Local**: Todo o payload de erro é criptografado localmente na sua máquina (usando AES-256) antes mesmo de trafegar pela rede. Seus dados chegam anônimos na nossa infraestrutura, e somente você consegue descriptografá-los com a sua chave secreta.
|
|
14
|
-
- 🔁 **Resiliência e Retentativa Automática**: Falhas de rede ou indisponibilidade? O SDK mantém o evento a salvo em memória e aplica _backoff exponencial_ para garantir a entrega quando a conexão for reestabelecida.
|
|
12
|
+
## Por que usar?
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+
- **Zero latência no fluxo principal** — o evento é enfileirado em memória e a aplicação segue sem esperar resposta de rede.
|
|
15
|
+
- **Criptografia local AES-256** — o payload é criptografado antes de sair do seu processo.
|
|
16
|
+
- **Entrega resiliente** — retry automático com backoff exponencial em caso de falha de rede.
|
|
17
|
+
- **Sem config extra** — o comportamento de retry, buffer e retomada é totalmente automático, determinado pela configuração da conta na plataforma.
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
> **Pré-requisito:** uma conta e um projeto ativos em [rebound-dlq.com](https://rebound-dlq.com).
|
|
20
|
+
|
|
21
|
+
---
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
## Instalação
|
|
21
24
|
|
|
22
25
|
```bash
|
|
23
26
|
npm install @rebound-dlq/node
|
|
24
27
|
```
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
---
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
## Configuração
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
A única configuração obrigatória é o `projectSecret`, obtido no painel da plataforma Rebound DLQ.
|
|
34
|
+
|
|
35
|
+
Nunca exponha a chave no código-fonte. Use variáveis de ambiente:
|
|
31
36
|
|
|
32
37
|
```env
|
|
33
|
-
|
|
34
|
-
REBOUND_PROJECT_SECRET="sk_dev_sua_chave_secreta_aqui"
|
|
38
|
+
REBOUND_PROJECT_SECRET="seu_project_secret_aqui"
|
|
35
39
|
```
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
> - Chaves iniciadas com `sk_dev_` enviarão eventos automaticamente para o ambiente de testes/sandbox.
|
|
39
|
-
> - Chaves iniciadas com `sk_live_` enviarão eventos para o ambiente de produção.
|
|
41
|
+
---
|
|
40
42
|
|
|
41
|
-
##
|
|
43
|
+
## Interfaces disponíveis
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
### `DlqWrapper` — recomendado
|
|
44
46
|
|
|
45
|
-
Se
|
|
47
|
+
Encapsula qualquer função assíncrona. Se ela lançar uma exceção, o evento de erro é capturado, criptografado e enviado automaticamente em background.
|
|
46
48
|
|
|
47
|
-
```
|
|
49
|
+
```ts
|
|
48
50
|
import { DlqWrapper } from '@rebound-dlq/node';
|
|
49
51
|
|
|
50
|
-
// Inicializa o SDK consumindo de variáveis de ambiente
|
|
51
52
|
const dlq = new DlqWrapper({
|
|
52
|
-
projectSecret: process.env.REBOUND_PROJECT_SECRET
|
|
53
|
+
projectSecret: process.env.REBOUND_PROJECT_SECRET!,
|
|
53
54
|
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Parâmetros:**
|
|
58
|
+
|
|
59
|
+
| Campo | Obrigatório | Descrição |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `projectSecret` | ✅ Sim | Chave secreta do projeto, usada para autenticação e criptografia local. |
|
|
62
|
+
|
|
63
|
+
**Uso:**
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const resultado = await dlq.execute(() => minhaFuncaoQuePodefFalhar(dados), dados);
|
|
67
|
+
```
|
|
54
68
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
O erro original é sempre relançado para o seu tratamento normal. O SDK apenas captura, criptografa e envia o evento em background.
|
|
70
|
+
|
|
71
|
+
**Exemplo completo:**
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { DlqWrapper } from '@rebound-dlq/node';
|
|
75
|
+
|
|
76
|
+
const dlq = new DlqWrapper({
|
|
77
|
+
projectSecret: process.env.REBOUND_PROJECT_SECRET!,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
async function processarPagamento(dados: { userId: string; valor: number }) {
|
|
81
|
+
if (dados.valor > 10000) {
|
|
82
|
+
throw new Error('Limite excedido na operadora');
|
|
59
83
|
}
|
|
60
|
-
return '
|
|
84
|
+
return 'aprovado';
|
|
61
85
|
}
|
|
62
86
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
userId: '123',
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
console.log("Iniciando transação...");
|
|
74
|
-
|
|
75
|
-
// Protegemos a função com o wrapper. Se der erro dentro de processarPagamento,
|
|
76
|
-
// o dlq envia os detalhes do erro E o payloadQueSeraSalvoNaDLQ para o Rebound automatically!
|
|
77
|
-
const resultado = await dlq.execute(
|
|
78
|
-
() => processarPagamento(payloadQueSeraSalvoNaDLQ),
|
|
79
|
-
payloadQueSeraSalvoNaDLQ
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
console.log('Finalizado com:', resultado);
|
|
83
|
-
|
|
84
|
-
} catch (erro) {
|
|
85
|
-
// A aplicação não morre; o erro é lançado apenas para seu tratamento habitual
|
|
86
|
-
console.log('Aconteceu um erro previsto na aplicação:', erro.message);
|
|
87
|
-
}
|
|
87
|
+
try {
|
|
88
|
+
const resultado = await dlq.execute(
|
|
89
|
+
() => processarPagamento({ userId: 'u-123', valor: 15000 }),
|
|
90
|
+
{ userId: 'u-123', valor: 15000 },
|
|
91
|
+
);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// erro relançado normalmente — o SDK já registrou o evento
|
|
94
|
+
console.error((err as Error).message);
|
|
88
95
|
}
|
|
96
|
+
```
|
|
89
97
|
|
|
90
|
-
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
### `ReboundClient` + adapters — uso avançado
|
|
101
|
+
|
|
102
|
+
Para enviar eventos manualmente ou usar com adapters (HTTP, Kafka etc.).
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { ReboundClient, HTTPAdapter } from '@rebound-dlq/node';
|
|
106
|
+
|
|
107
|
+
const client = new ReboundClient({
|
|
108
|
+
projectSecret: process.env.REBOUND_PROJECT_SECRET!,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const httpDlq = new HTTPAdapter(client);
|
|
112
|
+
|
|
113
|
+
await httpDlq.sendToDLQ({
|
|
114
|
+
payload: { orderId: 'ORD-1001', amount: 99.9 },
|
|
115
|
+
error: new Error('Payment gateway timeout'),
|
|
116
|
+
metadata: {
|
|
117
|
+
endpoint: '/api/payments',
|
|
118
|
+
method: 'POST',
|
|
119
|
+
tenantId: 'acme',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
91
122
|
```
|
|
92
123
|
|
|
93
|
-
|
|
124
|
+
**Parâmetros do `ReboundClient`:**
|
|
125
|
+
|
|
126
|
+
| Campo | Obrigatório | Descrição |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `projectSecret` | ✅ Sim | Chave secreta do projeto, usada para autenticação e criptografia local. |
|
|
94
129
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Comportamento automático
|
|
133
|
+
|
|
134
|
+
O SDK gerencia entrega, retry e limites de plano de forma totalmente automática, sem configuração adicional.
|
|
135
|
+
|
|
136
|
+
| Situação | O que o SDK faz |
|
|
137
|
+
|---|---|
|
|
138
|
+
| Sucesso na entrega | Remove o evento da fila |
|
|
139
|
+
| Falha de rede / erro transiente | Retry com backoff exponencial |
|
|
140
|
+
| Conta no limite (sem cobrança extra) | Descarta eventos e reconsulta o estado periodicamente |
|
|
141
|
+
| Conta no limite (com cobrança extra ativa) | Mantém eventos em buffer por até 15 min e retenta quando liberado |
|
|
142
|
+
| Conta desbloqueada / upgrade | Retoma o envio automaticamente, sem reiniciar o processo |
|
|
143
|
+
|
|
144
|
+
> A política de limite e cobrança extra é definida na sua conta na plataforma, não no SDK.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Observações operacionais
|
|
149
|
+
|
|
150
|
+
- A fila é **in-memory**: eventos não sobrevivem a restart do processo.
|
|
151
|
+
- Para workloads efêmeros, prefira processos com **shutdown gracioso**.
|
|
152
|
+
- O SDK emite logs no console ao entrar em modo de retry, buffer ou descarte.
|
|
153
|
+
|
|
154
|
+
---
|
|
100
155
|
|
|
101
156
|
## Suporte
|
|
102
157
|
|
|
103
|
-
|
|
158
|
+
Dúvidas de integração ou comportamento? Abra um chamado pela plataforma oficial em [rebound-dlq.com](https://rebound-dlq.com).
|
|
159
|
+
|
|
160
|
+
---
|
|
104
161
|
|
|
105
|
-
|
|
106
|
-
_Feito com ❤️ por **[Codify Labs] / [Rebound DLQ]**._
|
|
162
|
+
Feito por **Codify Labs / Rebound DLQ**.
|
package/dist/core/client.d.ts
CHANGED
package/dist/core/client.js
CHANGED
|
@@ -1,25 +1,68 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.ReboundClient = void 0;
|
|
7
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
4
8
|
const constants_1 = require("./constants");
|
|
9
|
+
const endpoint_1 = require("./endpoint");
|
|
5
10
|
const queue_1 = require("./queue");
|
|
6
11
|
class ReboundClient {
|
|
7
12
|
constructor(config) {
|
|
8
13
|
this.config = config;
|
|
9
|
-
const
|
|
10
|
-
this.baseUrl =
|
|
11
|
-
(isTest
|
|
12
|
-
? constants_1.REBOUND_API_ENDPOINTS.TEST
|
|
13
|
-
: constants_1.REBOUND_API_ENDPOINTS.PRODUCTION);
|
|
14
|
+
const configuredEndpoint = config.endpoint || constants_1.DEFAULT_REBOUND_API_ENDPOINT;
|
|
15
|
+
this.baseUrl = (0, endpoint_1.resolveDlqBaseEndpoint)(configuredEndpoint);
|
|
14
16
|
}
|
|
15
17
|
async send(payload) {
|
|
16
|
-
const endpoint =
|
|
17
|
-
const
|
|
18
|
-
|
|
18
|
+
const endpoint = (0, endpoint_1.resolveDlqIngestionEndpoint)(this.baseUrl);
|
|
19
|
+
const ingestionPayload = this.buildIngestionPayload(payload);
|
|
20
|
+
// Enqueue directly in memory so the application doesn't block on network responses
|
|
21
|
+
queue_1.MemoryQueue.getInstance().enqueue(endpoint, this.config.projectSecret, ingestionPayload);
|
|
22
|
+
}
|
|
23
|
+
buildIngestionPayload(payload) {
|
|
24
|
+
const normalizedError = this.normalizeError(payload.error);
|
|
25
|
+
const envelope = {
|
|
26
|
+
payload: payload.payload,
|
|
27
|
+
source: payload.source,
|
|
28
|
+
metadata: payload.metadata ?? {},
|
|
19
29
|
timestamp: payload.timestamp || Date.now(),
|
|
20
30
|
};
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
return {
|
|
32
|
+
fingerprint: this.generateFingerprint(normalizedError, payload.source),
|
|
33
|
+
error: normalizedError,
|
|
34
|
+
payload: this.encrypt(envelope),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
normalizeError(error) {
|
|
38
|
+
if (!error) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
message: error.message,
|
|
43
|
+
stack: error.stack,
|
|
44
|
+
name: error.name,
|
|
45
|
+
code: error.code !== undefined ? String(error.code) : undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
generateFingerprint(error, source) {
|
|
49
|
+
const stack = (error?.stack || '').split('\n').slice(0, 3).join('');
|
|
50
|
+
const seed = `${error?.name || 'UnknownError'}${error?.message || ''}${stack}${source}`;
|
|
51
|
+
return crypto_1.default.createHash('sha256').update(seed).digest('hex');
|
|
52
|
+
}
|
|
53
|
+
encrypt(payload) {
|
|
54
|
+
const algorithm = 'aes-256-cbc';
|
|
55
|
+
const key = crypto_1.default
|
|
56
|
+
.createHash('sha256')
|
|
57
|
+
.update(String(this.config.projectSecret))
|
|
58
|
+
.digest('base64')
|
|
59
|
+
.substring(0, 32);
|
|
60
|
+
const iv = crypto_1.default.randomBytes(16);
|
|
61
|
+
const payloadString = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
62
|
+
const cipher = crypto_1.default.createCipheriv(algorithm, Buffer.from(key), iv);
|
|
63
|
+
let encrypted = cipher.update(payloadString, 'utf8', 'hex');
|
|
64
|
+
encrypted += cipher.final('hex');
|
|
65
|
+
return `${iv.toString('hex')}:${encrypted}`;
|
|
23
66
|
}
|
|
24
67
|
}
|
|
25
68
|
exports.ReboundClient = ReboundClient;
|
package/dist/core/constants.d.ts
CHANGED
package/dist/core/constants.js
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
4
|
-
exports.
|
|
5
|
-
TEST: 'https://api.rebound-dlq.com/api/v1',
|
|
6
|
-
PRODUCTION: 'https://api.rebound-dlq.com/api/v1'
|
|
7
|
-
};
|
|
3
|
+
exports.DEFAULT_REBOUND_API_ENDPOINT = void 0;
|
|
4
|
+
exports.DEFAULT_REBOUND_API_ENDPOINT = 'http://localhost:3001/api/v1';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveDlqIngestionEndpoint = resolveDlqIngestionEndpoint;
|
|
4
|
+
exports.resolveDlqBaseEndpoint = resolveDlqBaseEndpoint;
|
|
5
|
+
const DLQ_INGESTION_SEGMENT = '/dlq-injestion';
|
|
6
|
+
function resolveDlqIngestionEndpoint(endpoint) {
|
|
7
|
+
const normalizedEndpoint = endpoint.replace(/\/+$/, '');
|
|
8
|
+
if (normalizedEndpoint.endsWith(DLQ_INGESTION_SEGMENT)) {
|
|
9
|
+
return normalizedEndpoint;
|
|
10
|
+
}
|
|
11
|
+
return `${normalizedEndpoint}${DLQ_INGESTION_SEGMENT}`;
|
|
12
|
+
}
|
|
13
|
+
function resolveDlqBaseEndpoint(endpoint) {
|
|
14
|
+
const normalizedEndpoint = endpoint.replace(/\/+$/, '');
|
|
15
|
+
if (normalizedEndpoint.endsWith(DLQ_INGESTION_SEGMENT)) {
|
|
16
|
+
return normalizedEndpoint.slice(0, -DLQ_INGESTION_SEGMENT.length);
|
|
17
|
+
}
|
|
18
|
+
return normalizedEndpoint;
|
|
19
|
+
}
|
package/dist/core/queue.d.ts
CHANGED
|
@@ -2,11 +2,34 @@ export declare class MemoryQueue {
|
|
|
2
2
|
private static instance;
|
|
3
3
|
private queue;
|
|
4
4
|
private isProcessing;
|
|
5
|
+
private isProcessingScheduled;
|
|
5
6
|
private baseDelayMs;
|
|
6
7
|
private maxDelayMs;
|
|
8
|
+
private blockedStates;
|
|
7
9
|
private constructor();
|
|
8
10
|
static getInstance(): MemoryQueue;
|
|
9
|
-
enqueue(endpoint: string,
|
|
11
|
+
enqueue(endpoint: string, projectSecret: string, payload: any): void;
|
|
10
12
|
private processQueue;
|
|
11
13
|
private applyBackoff;
|
|
14
|
+
private ensureProcessing;
|
|
15
|
+
private sendSignedJsonRequest;
|
|
16
|
+
private parseBlockedResponse;
|
|
17
|
+
private normalizeBlockedPayload;
|
|
18
|
+
private activateBlockedState;
|
|
19
|
+
private routeBlockedItem;
|
|
20
|
+
private bufferItem;
|
|
21
|
+
private pruneExpiredBufferedItems;
|
|
22
|
+
private scheduleProbe;
|
|
23
|
+
private probeBlockedState;
|
|
24
|
+
private fetchRuntimeState;
|
|
25
|
+
private normalizeRuntimeStateResponse;
|
|
26
|
+
private resumeBlockedState;
|
|
27
|
+
private logBlockedMode;
|
|
28
|
+
private buildQueueKey;
|
|
29
|
+
private buildRuntimeStateEndpoint;
|
|
30
|
+
private normalizeRecheckAfterMs;
|
|
31
|
+
private normalizeBufferWindowMs;
|
|
32
|
+
private isJsonResponse;
|
|
33
|
+
private isRecord;
|
|
34
|
+
private buildRetryWarning;
|
|
12
35
|
}
|
package/dist/core/queue.js
CHANGED
|
@@ -35,12 +35,21 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.MemoryQueue = void 0;
|
|
37
37
|
const crypto = __importStar(require("crypto"));
|
|
38
|
+
const DLQ_INGESTION_BLOCK_CODE = 'DLQ_INGESTION_BLOCKED_LIMIT';
|
|
39
|
+
const DEFAULT_RECHECK_AFTER_MS = 60 * 1000;
|
|
40
|
+
const DEFAULT_BUFFER_WINDOW_MS = 15 * 60 * 1000;
|
|
41
|
+
const MIN_RECHECK_AFTER_MS = 15 * 1000;
|
|
42
|
+
const MIN_BUFFER_WINDOW_MS = 60 * 1000;
|
|
43
|
+
const MAX_BUFFER_WINDOW_MS = 60 * 60 * 1000;
|
|
44
|
+
const MAX_BUFFERED_ITEMS_PER_KEY = 250;
|
|
38
45
|
class MemoryQueue {
|
|
39
46
|
constructor() {
|
|
40
47
|
this.queue = [];
|
|
41
48
|
this.isProcessing = false;
|
|
49
|
+
this.isProcessingScheduled = false;
|
|
42
50
|
this.baseDelayMs = 1000;
|
|
43
51
|
this.maxDelayMs = 60000; // max 60 seconds between retries
|
|
52
|
+
this.blockedStates = new Map();
|
|
44
53
|
}
|
|
45
54
|
static getInstance() {
|
|
46
55
|
if (!MemoryQueue.instance) {
|
|
@@ -48,70 +57,330 @@ class MemoryQueue {
|
|
|
48
57
|
}
|
|
49
58
|
return MemoryQueue.instance;
|
|
50
59
|
}
|
|
51
|
-
enqueue(endpoint,
|
|
52
|
-
|
|
60
|
+
enqueue(endpoint, projectSecret, payload) {
|
|
61
|
+
const item = {
|
|
53
62
|
endpoint,
|
|
54
|
-
|
|
63
|
+
projectSecret,
|
|
55
64
|
payload,
|
|
56
65
|
retryCount: 0
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}, 0);
|
|
66
|
+
};
|
|
67
|
+
const queueKey = this.buildQueueKey(endpoint, projectSecret);
|
|
68
|
+
const blockedState = this.blockedStates.get(queueKey);
|
|
69
|
+
if (blockedState) {
|
|
70
|
+
this.routeBlockedItem(item, blockedState);
|
|
71
|
+
this.scheduleProbe(queueKey, blockedState);
|
|
72
|
+
return;
|
|
65
73
|
}
|
|
74
|
+
this.queue.push(item);
|
|
75
|
+
this.ensureProcessing();
|
|
66
76
|
}
|
|
67
77
|
async processQueue() {
|
|
68
78
|
this.isProcessing = true;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
const signature = crypto
|
|
76
|
-
.createHmac('sha256', item.apiKey)
|
|
77
|
-
.update(dataToSign)
|
|
78
|
-
.digest('hex');
|
|
79
|
-
const response = await fetch(item.endpoint, {
|
|
80
|
-
method: 'POST',
|
|
81
|
-
headers: {
|
|
82
|
-
'Content-Type': 'application/json',
|
|
83
|
-
'x-api-key': `Bearer ${item.apiKey}`,
|
|
84
|
-
'x-sdk-timestamp': timestamp,
|
|
85
|
-
'x-sdk-signature': signature
|
|
86
|
-
},
|
|
87
|
-
body: payloadString,
|
|
88
|
-
});
|
|
89
|
-
if (response.ok) {
|
|
90
|
-
if (item.retryCount > 0) {
|
|
91
|
-
console.log(`[Rebound SDK] Event successfully delivered after ${item.retryCount} retries.`);
|
|
92
|
-
}
|
|
93
|
-
// Success: Remove item from memory queue
|
|
79
|
+
try {
|
|
80
|
+
while (this.queue.length > 0) {
|
|
81
|
+
const item = this.queue[0]; // FIFO
|
|
82
|
+
const queueKey = this.buildQueueKey(item.endpoint, item.projectSecret);
|
|
83
|
+
const blockedState = this.blockedStates.get(queueKey);
|
|
84
|
+
if (blockedState) {
|
|
94
85
|
this.queue.shift();
|
|
86
|
+
this.routeBlockedItem(item, blockedState);
|
|
87
|
+
this.scheduleProbe(queueKey, blockedState);
|
|
88
|
+
continue;
|
|
95
89
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
90
|
+
try {
|
|
91
|
+
const response = await this.sendSignedJsonRequest(item.endpoint, item.projectSecret, item.payload);
|
|
92
|
+
if (response.ok) {
|
|
93
|
+
if (item.retryCount > 0) {
|
|
94
|
+
console.log(`[Rebound SDK] Event successfully delivered after ${item.retryCount} retries.`);
|
|
95
|
+
}
|
|
96
|
+
this.queue.shift();
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const blockedResponse = await this.parseBlockedResponse(response);
|
|
100
|
+
if (blockedResponse) {
|
|
101
|
+
this.queue.shift();
|
|
102
|
+
this.activateBlockedState(item, blockedResponse);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
await this.applyBackoff(item, {
|
|
106
|
+
reason: 'http',
|
|
107
|
+
statusCode: response.status,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
// Network unstable / Connection refused
|
|
112
|
+
await this.applyBackoff(item, { reason: 'network' });
|
|
99
113
|
}
|
|
100
|
-
}
|
|
101
|
-
catch (error) {
|
|
102
|
-
// Network unstable / Connection refused
|
|
103
|
-
await this.applyBackoff(item);
|
|
104
114
|
}
|
|
105
115
|
}
|
|
106
|
-
|
|
116
|
+
finally {
|
|
117
|
+
this.isProcessing = false;
|
|
118
|
+
}
|
|
107
119
|
}
|
|
108
|
-
async applyBackoff(item) {
|
|
120
|
+
async applyBackoff(item, context) {
|
|
109
121
|
item.retryCount++;
|
|
110
122
|
const delay = Math.min(this.baseDelayMs * Math.pow(2, item.retryCount), this.maxDelayMs);
|
|
111
123
|
if (item.retryCount === 1) {
|
|
112
|
-
console.warn(
|
|
124
|
+
console.warn(this.buildRetryWarning(context));
|
|
113
125
|
}
|
|
114
126
|
return new Promise((resolve) => setTimeout(resolve, delay));
|
|
115
127
|
}
|
|
128
|
+
ensureProcessing() {
|
|
129
|
+
if (this.isProcessing || this.isProcessingScheduled) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.isProcessingScheduled = true;
|
|
133
|
+
// Offload to Event Loop so it returns instantly for the main application thread
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
this.isProcessingScheduled = false;
|
|
136
|
+
this.processQueue().catch((err) => {
|
|
137
|
+
console.error('[Rebound SDK] Unexpected queue error:', err);
|
|
138
|
+
});
|
|
139
|
+
}, 0);
|
|
140
|
+
}
|
|
141
|
+
async sendSignedJsonRequest(endpoint, projectSecret, payload) {
|
|
142
|
+
const timestamp = Date.now().toString();
|
|
143
|
+
const payloadString = JSON.stringify(payload);
|
|
144
|
+
const dataToSign = `${timestamp}.${payloadString}`;
|
|
145
|
+
const signature = crypto
|
|
146
|
+
.createHmac('sha256', projectSecret)
|
|
147
|
+
.update(dataToSign)
|
|
148
|
+
.digest('hex');
|
|
149
|
+
return fetch(endpoint, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: {
|
|
152
|
+
'Content-Type': 'application/json',
|
|
153
|
+
'x-api-key': `Bearer ${projectSecret}`,
|
|
154
|
+
'x-sdk-timestamp': timestamp,
|
|
155
|
+
'x-sdk-signature': signature
|
|
156
|
+
},
|
|
157
|
+
body: payloadString,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async parseBlockedResponse(response) {
|
|
161
|
+
if (response.status !== 403 || !this.isJsonResponse(response)) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const payload = await response.json();
|
|
166
|
+
return this.normalizeBlockedPayload(payload);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
normalizeBlockedPayload(payload) {
|
|
173
|
+
if (!this.isRecord(payload)) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
if (payload.code !== DLQ_INGESTION_BLOCK_CODE || payload.retryable !== false) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const queueAction = payload.queueAction === 'buffer' ? 'buffer' : 'discard';
|
|
180
|
+
const bufferWindowMs = queueAction === 'buffer'
|
|
181
|
+
? this.normalizeBufferWindowMs(payload.bufferWindowSeconds)
|
|
182
|
+
: 0;
|
|
183
|
+
return {
|
|
184
|
+
queueAction,
|
|
185
|
+
bufferWindowMs,
|
|
186
|
+
recheckAfterMs: this.normalizeRecheckAfterMs(payload.recheckAfterSeconds),
|
|
187
|
+
currentPeriodEnd: typeof payload.currentPeriodEnd === 'string' ? payload.currentPeriodEnd : null,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
activateBlockedState(item, payload) {
|
|
191
|
+
const queueKey = this.buildQueueKey(item.endpoint, item.projectSecret);
|
|
192
|
+
const previousState = this.blockedStates.get(queueKey) ?? null;
|
|
193
|
+
const previousAction = previousState?.queueAction ?? null;
|
|
194
|
+
const blockedState = previousState ?? {
|
|
195
|
+
endpoint: item.endpoint,
|
|
196
|
+
projectSecret: item.projectSecret,
|
|
197
|
+
queueAction: payload.queueAction,
|
|
198
|
+
bufferWindowMs: payload.bufferWindowMs,
|
|
199
|
+
recheckAfterMs: payload.recheckAfterMs,
|
|
200
|
+
currentPeriodEnd: payload.currentPeriodEnd,
|
|
201
|
+
buffer: [],
|
|
202
|
+
timer: null,
|
|
203
|
+
probeInFlight: false,
|
|
204
|
+
};
|
|
205
|
+
blockedState.queueAction = payload.queueAction;
|
|
206
|
+
blockedState.bufferWindowMs = payload.bufferWindowMs;
|
|
207
|
+
blockedState.recheckAfterMs = payload.recheckAfterMs;
|
|
208
|
+
blockedState.currentPeriodEnd = payload.currentPeriodEnd;
|
|
209
|
+
if (blockedState.queueAction === 'discard' && blockedState.buffer.length > 0) {
|
|
210
|
+
blockedState.buffer = [];
|
|
211
|
+
}
|
|
212
|
+
this.blockedStates.set(queueKey, blockedState);
|
|
213
|
+
this.routeBlockedItem(item, blockedState);
|
|
214
|
+
this.scheduleProbe(queueKey, blockedState);
|
|
215
|
+
if (!previousState || previousAction !== blockedState.queueAction) {
|
|
216
|
+
this.logBlockedMode(blockedState);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
routeBlockedItem(item, blockedState) {
|
|
220
|
+
if (blockedState.queueAction === 'buffer') {
|
|
221
|
+
this.bufferItem(blockedState, item);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
bufferItem(blockedState, item) {
|
|
226
|
+
this.pruneExpiredBufferedItems(blockedState);
|
|
227
|
+
blockedState.buffer.push({
|
|
228
|
+
...item,
|
|
229
|
+
expiresAt: Date.now() + blockedState.bufferWindowMs,
|
|
230
|
+
});
|
|
231
|
+
if (blockedState.buffer.length <= MAX_BUFFERED_ITEMS_PER_KEY) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const overflowCount = blockedState.buffer.length - MAX_BUFFERED_ITEMS_PER_KEY;
|
|
235
|
+
blockedState.buffer.splice(0, overflowCount);
|
|
236
|
+
console.warn(`[Rebound SDK] Buffer cap reached while DLQ ingestion is blocked. Dropped ${overflowCount} older buffered event(s).`);
|
|
237
|
+
}
|
|
238
|
+
pruneExpiredBufferedItems(blockedState) {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
const originalLength = blockedState.buffer.length;
|
|
241
|
+
blockedState.buffer = blockedState.buffer.filter((item) => item.expiresAt > now);
|
|
242
|
+
const droppedCount = originalLength - blockedState.buffer.length;
|
|
243
|
+
if (droppedCount > 0) {
|
|
244
|
+
console.warn(`[Rebound SDK] Dropped ${droppedCount} buffered event(s) after the temporary blocked window expired.`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
scheduleProbe(queueKey, blockedState) {
|
|
248
|
+
if (blockedState.timer) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
blockedState.timer = setTimeout(() => {
|
|
252
|
+
blockedState.timer = null;
|
|
253
|
+
this.probeBlockedState(queueKey).catch((error) => {
|
|
254
|
+
console.error('[Rebound SDK] Failed to refresh blocked ingestion state:', error);
|
|
255
|
+
});
|
|
256
|
+
}, blockedState.recheckAfterMs);
|
|
257
|
+
}
|
|
258
|
+
async probeBlockedState(queueKey) {
|
|
259
|
+
const blockedState = this.blockedStates.get(queueKey);
|
|
260
|
+
if (!blockedState || blockedState.probeInFlight) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
blockedState.probeInFlight = true;
|
|
264
|
+
try {
|
|
265
|
+
this.pruneExpiredBufferedItems(blockedState);
|
|
266
|
+
const runtimeState = await this.fetchRuntimeState(blockedState);
|
|
267
|
+
if (!runtimeState) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (runtimeState.allowed) {
|
|
271
|
+
this.resumeBlockedState(queueKey, blockedState);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const previousAction = blockedState.queueAction;
|
|
275
|
+
blockedState.queueAction = runtimeState.queueAction;
|
|
276
|
+
blockedState.bufferWindowMs = runtimeState.bufferWindowMs;
|
|
277
|
+
blockedState.recheckAfterMs = runtimeState.recheckAfterMs;
|
|
278
|
+
blockedState.currentPeriodEnd = runtimeState.currentPeriodEnd;
|
|
279
|
+
if (blockedState.queueAction === 'discard' && blockedState.buffer.length > 0) {
|
|
280
|
+
blockedState.buffer = [];
|
|
281
|
+
}
|
|
282
|
+
if (previousAction !== blockedState.queueAction) {
|
|
283
|
+
this.logBlockedMode(blockedState);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
blockedState.probeInFlight = false;
|
|
288
|
+
if (this.blockedStates.has(queueKey)) {
|
|
289
|
+
this.scheduleProbe(queueKey, blockedState);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async fetchRuntimeState(blockedState) {
|
|
294
|
+
const response = await this.sendSignedJsonRequest(this.buildRuntimeStateEndpoint(blockedState.endpoint), blockedState.projectSecret, {});
|
|
295
|
+
if (!response.ok || !this.isJsonResponse(response)) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const payload = await response.json();
|
|
300
|
+
return this.normalizeRuntimeStateResponse(payload);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
normalizeRuntimeStateResponse(payload) {
|
|
307
|
+
if (!this.isRecord(payload) || typeof payload.allowed !== 'boolean') {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
if (payload.allowed) {
|
|
311
|
+
return {
|
|
312
|
+
allowed: true,
|
|
313
|
+
queueAction: 'discard',
|
|
314
|
+
bufferWindowMs: 0,
|
|
315
|
+
recheckAfterMs: this.normalizeRecheckAfterMs(payload.recheckAfterSeconds),
|
|
316
|
+
currentPeriodEnd: typeof payload.currentPeriodEnd === 'string' ? payload.currentPeriodEnd : null,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const blockedPayload = this.normalizeBlockedPayload(payload);
|
|
320
|
+
if (!blockedPayload) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
allowed: false,
|
|
325
|
+
...blockedPayload,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
resumeBlockedState(queueKey, blockedState) {
|
|
329
|
+
this.blockedStates.delete(queueKey);
|
|
330
|
+
if (blockedState.timer) {
|
|
331
|
+
clearTimeout(blockedState.timer);
|
|
332
|
+
blockedState.timer = null;
|
|
333
|
+
}
|
|
334
|
+
this.pruneExpiredBufferedItems(blockedState);
|
|
335
|
+
if (blockedState.buffer.length > 0) {
|
|
336
|
+
const bufferedItems = blockedState.buffer.map(({ expiresAt, ...item }) => item);
|
|
337
|
+
this.queue = [...bufferedItems, ...this.queue];
|
|
338
|
+
console.log(`[Rebound SDK] DLQ ingestion resumed. Replaying ${bufferedItems.length} buffered event(s).`);
|
|
339
|
+
}
|
|
340
|
+
if (this.queue.length > 0) {
|
|
341
|
+
this.ensureProcessing();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
logBlockedMode(blockedState) {
|
|
345
|
+
const cycleHint = blockedState.currentPeriodEnd
|
|
346
|
+
? ` Current cycle ends at ${blockedState.currentPeriodEnd}.`
|
|
347
|
+
: '';
|
|
348
|
+
if (blockedState.queueAction === 'buffer') {
|
|
349
|
+
const bufferMinutes = Math.round(blockedState.bufferWindowMs / 60000);
|
|
350
|
+
console.warn(`[Rebound SDK] DLQ ingestion is temporarily blocked by the account plan. Buffering events for up to ${bufferMinutes} minute(s) while the account plan or billing settings are adjusted.${cycleHint}`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
console.warn(`[Rebound SDK] DLQ ingestion is blocked by the account plan and extra usage is disabled. New events will be discarded until the account becomes eligible again.${cycleHint}`);
|
|
354
|
+
}
|
|
355
|
+
buildQueueKey(endpoint, projectSecret) {
|
|
356
|
+
return `${endpoint}::${projectSecret}`;
|
|
357
|
+
}
|
|
358
|
+
buildRuntimeStateEndpoint(endpoint) {
|
|
359
|
+
return `${endpoint.replace(/\/+$/, '')}/runtime-state`;
|
|
360
|
+
}
|
|
361
|
+
normalizeRecheckAfterMs(value) {
|
|
362
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
363
|
+
return DEFAULT_RECHECK_AFTER_MS;
|
|
364
|
+
}
|
|
365
|
+
return Math.max(Math.trunc(value * 1000), MIN_RECHECK_AFTER_MS);
|
|
366
|
+
}
|
|
367
|
+
normalizeBufferWindowMs(value) {
|
|
368
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
369
|
+
return DEFAULT_BUFFER_WINDOW_MS;
|
|
370
|
+
}
|
|
371
|
+
return Math.min(Math.max(Math.trunc(value * 1000), MIN_BUFFER_WINDOW_MS), MAX_BUFFER_WINDOW_MS);
|
|
372
|
+
}
|
|
373
|
+
isJsonResponse(response) {
|
|
374
|
+
return (response.headers.get('content-type') ?? '').includes('application/json');
|
|
375
|
+
}
|
|
376
|
+
isRecord(value) {
|
|
377
|
+
return typeof value === 'object' && value !== null;
|
|
378
|
+
}
|
|
379
|
+
buildRetryWarning(context) {
|
|
380
|
+
if (context.reason === 'http' && context.statusCode) {
|
|
381
|
+
return `[Rebound SDK] DLQ API rejected the event with HTTP ${context.statusCode}. Retrying in background while the issue persists...`;
|
|
382
|
+
}
|
|
383
|
+
return '[Rebound SDK] Connectivity issue sending DLQ event. Retrying in background to guarantee delivery...';
|
|
384
|
+
}
|
|
116
385
|
}
|
|
117
386
|
exports.MemoryQueue = MemoryQueue;
|
package/dist/core/wrapper.js
CHANGED
|
@@ -6,16 +6,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.DlqWrapper = void 0;
|
|
7
7
|
const crypto_1 = __importDefault(require("crypto"));
|
|
8
8
|
const constants_1 = require("./constants");
|
|
9
|
+
const endpoint_1 = require("./endpoint");
|
|
9
10
|
const queue_1 = require("./queue");
|
|
10
11
|
class DlqWrapper {
|
|
11
12
|
constructor(config) {
|
|
12
13
|
this.projectSecret = config.projectSecret;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
this.endpoint = config.endpoint ||
|
|
16
|
-
(isTest
|
|
17
|
-
? constants_1.REBOUND_API_ENDPOINTS.TEST
|
|
18
|
-
: constants_1.REBOUND_API_ENDPOINTS.PRODUCTION) + '/dlq-injestion'; // Usar o sufixo apropriado
|
|
14
|
+
const configuredEndpoint = config.endpoint || constants_1.DEFAULT_REBOUND_API_ENDPOINT;
|
|
15
|
+
this.endpoint = (0, endpoint_1.resolveDlqIngestionEndpoint)(configuredEndpoint);
|
|
19
16
|
}
|
|
20
17
|
async execute(fn, payload) {
|
|
21
18
|
try {
|
package/dist/models/payload.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@rebound-dlq/node",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"main": "dist/index.js",
|
|
5
|
-
"types": "dist/index.d.ts",
|
|
6
|
-
"files": ["dist"],
|
|
7
|
-
"scripts": {
|
|
8
|
-
"build": "tsc",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
}
|
|
18
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@rebound-dlq/node",
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"files": ["dist"],
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"build:examples": "node scripts/run-example.cjs --build-only",
|
|
10
|
+
"dev": "tsc --watch",
|
|
11
|
+
"json-server": "npx json-server --watch mock-server.json --port 3005",
|
|
12
|
+
"example:wrapper": "node scripts/run-example.cjs wrapper-example",
|
|
13
|
+
"example:http": "node scripts/run-example.cjs http-example",
|
|
14
|
+
"example:kafka": "node scripts/run-example.cjs kafka-example",
|
|
15
|
+
"example:internal": "node scripts/run-example.cjs internal-errors-example",
|
|
16
|
+
"test:wrapper": "node scripts/run-example.cjs wrapper-example"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^20.0.0",
|
|
20
|
+
"json-server": "^1.0.0-beta.3",
|
|
21
|
+
"typescript": "^5.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|