@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 CHANGED
@@ -1,106 +1,162 @@
1
- # Rebound DLQ for Node.js
1
+ # Rebound DLQ Node.js SDK
2
2
 
3
3
  [![NPM Version](https://img.shields.io/npm/v/@rebound-dlq/node)](https://www.npmjs.com/package/@rebound-dlq/node)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
- O **@rebound-dlq/node** é o SDK oficial em Node.js para integração com a plataforma **[Rebound DLQ](https://rebound-dlq.com)**.
6
+ SDK oficial em Node.js para integrar sua aplicação com a plataforma **[Rebound DLQ](https://rebound-dlq.com)**.
7
7
 
8
- Este pacote permite capturar falhas em integrações (APIs de terceiros, pagamentos, envio de e-mails, etc.) de forma transparente para a sua aplicação Node.js. Sem travar sua regra de negócio e sem adicionar latência. Quando uma operação falha, o SDK captura o payload, criptografa a informação e envia o evento em plano de fundo (background) para a sua conta no Rebound DLQ, aplicando estratégias de retry exponencial automaticamente se houver instabilidade na rede.
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
- ## Por que usar o Rebound DLQ?
10
+ ---
11
11
 
12
- - **Zero Latência (In-Memory Queue)**: A aplicação não espera a resposta do envio do log para a nossa infraestrutura. A execução ocorre inteiramente em memória e em background, liberando o processo Node.js principal na hora (`O(1)`).
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
- > ⚠️ **Atenção**: Este SDK requer uma conta ativa na plataforma **[Rebound DLQ](https://rebound-dlq.com)**.
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
- ## Instalação
19
+ > **Pré-requisito:** uma conta e um projeto ativos em [rebound-dlq.com](https://rebound-dlq.com).
20
+
21
+ ---
19
22
 
20
- Abra o terminal e instale o pacote via npm, yarn ou pnpm:
23
+ ## Instalação
21
24
 
22
25
  ```bash
23
26
  npm install @rebound-dlq/node
24
27
  ```
25
28
 
26
- ## Configuração Segura
29
+ ---
27
30
 
28
- O SDK só precisa da chave de integração do seu projeto (`projectSecret`).
31
+ ## Configuração
29
32
 
30
- **Prática Recomendada:** Nunca exponha (hard-code) a sua chave diretamente no código-fonte. Armazene a sua chave `projectSecret` de forma segura utilizando variáveis de ambiente (ex: arquivo `.env`).
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
- # Arquivo .env
34
- REBOUND_PROJECT_SECRET="sk_dev_sua_chave_secreta_aqui"
38
+ REBOUND_PROJECT_SECRET="seu_project_secret_aqui"
35
39
  ```
36
40
 
37
- > **Dica de Ambiente**:
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
- ## Uso Básico
43
+ ## Interfaces disponíveis
42
44
 
43
- A forma ideal de utilizar o SDK é encapsular a sua função com o método `.execute()` oferecido pela classe `DlqWrapper`.
45
+ ### `DlqWrapper` recomendado
44
46
 
45
- Se atente à inicialização e uso:
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
- ```typescript
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
- // A função que se conecta ao seu provedor ou ao seu banco de dados (que pode falhar)
56
- async function processarPagamento(dados) {
57
- if (dados.valor > 1000) {
58
- throw new Error('Saldo insuficiente na operadora do cartão');
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 'Sucesso!';
84
+ return 'aprovado';
61
85
  }
62
86
 
63
- async function main() {
64
- // Os dados de contexto que você gostaria de analisar depois se algo der errado
65
- const payloadQueSeraSalvoNaDLQ = {
66
- userId: '123',
67
- valor: 5000,
68
- tentativa: 1,
69
- modulo: 'Checkout',
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
- main();
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
- ### O que acontece por baixo dos panos neste exemplo?
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
- 1. Ao chamar `.execute()`, se o `processarPagamento` lançar qualquer **exceção**.
96
- 2. Essa exceção é **capturada sem dor**.
97
- 3. A variável `payloadQueSeraSalvoNaDLQ` entra na **memória RAM do SDK**.
98
- 4. É processada **criptografia local AES-256** no dado, utilizando a sua chave.
99
- 5. Em Background, um processo envia de forma segura este evento para a plataforma Rebound, garantindo entrega do Dead Letter Queue para reprocessamento futuro ou simples visualização pela Dashboard.
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
- Em caso de dúvidas, problemas de implementação ou bugs, abra um chamado pela Dashboard oficial do [Rebound Platform](https://rebound-dlq.com).
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**.
@@ -29,7 +29,7 @@ class BaseAdapter {
29
29
  message: error.message,
30
30
  stack: error.stack,
31
31
  name: error.name,
32
- code: error.code
32
+ code: error.code !== undefined ? String(error.code) : undefined
33
33
  };
34
34
  }
35
35
  }
@@ -4,4 +4,8 @@ export declare class ReboundClient {
4
4
  private readonly baseUrl;
5
5
  constructor(config: ReboundConfig);
6
6
  send(payload: DLQPayload): Promise<void>;
7
+ private buildIngestionPayload;
8
+ private normalizeError;
9
+ private generateFingerprint;
10
+ private encrypt;
7
11
  }
@@ -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 isTest = config.apiKey.startsWith('sk_test_');
10
- this.baseUrl = config.endpoint ||
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 = `${this.baseUrl}/dlq-injestion`;
17
- const enrichedPayload = {
18
- ...payload,
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
- // Enqueue directly in memory so the application doesn't block on network responses
22
- queue_1.MemoryQueue.getInstance().enqueue(endpoint, this.config.apiKey, enrichedPayload);
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;
@@ -1,4 +1 @@
1
- export declare const REBOUND_API_ENDPOINTS: {
2
- readonly TEST: "https://api.rebound-dlq.com/api/v1";
3
- readonly PRODUCTION: "https://api.rebound-dlq.com/api/v1";
4
- };
1
+ export declare const DEFAULT_REBOUND_API_ENDPOINT = "http://localhost:3001/api/v1";
@@ -1,7 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.REBOUND_API_ENDPOINTS = void 0;
4
- exports.REBOUND_API_ENDPOINTS = {
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,2 @@
1
+ export declare function resolveDlqIngestionEndpoint(endpoint: string): string;
2
+ export declare function resolveDlqBaseEndpoint(endpoint: string): string;
@@ -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
+ }
@@ -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, apiKey: string, payload: any): void;
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
  }
@@ -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, apiKey, payload) {
52
- this.queue.push({
60
+ enqueue(endpoint, projectSecret, payload) {
61
+ const item = {
53
62
  endpoint,
54
- apiKey,
63
+ projectSecret,
55
64
  payload,
56
65
  retryCount: 0
57
- });
58
- if (!this.isProcessing) {
59
- // Offload to Event Loop so it returns instantly for the main application thread
60
- setTimeout(() => {
61
- this.processQueue().catch((err) => {
62
- console.error('[Rebound SDK] Unexpected queue error:', err);
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
- while (this.queue.length > 0) {
70
- const item = this.queue[0]; // FIFO
71
- try {
72
- const timestamp = Date.now().toString();
73
- const payloadString = JSON.stringify(item.payload);
74
- const dataToSign = `${timestamp}.${payloadString}`;
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
- else {
97
- // API error (ex 429, 500)
98
- await this.applyBackoff(item);
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
- this.isProcessing = false;
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(`[Rebound SDK] Connectivity issue sending DLQ event. Retrying in background to guarantee delivery...`);
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;
@@ -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
- // Identifica se é ambiente de teste através do projectSecret (mesma lógica da apiKey no ReboundClient)
14
- const isTest = config.projectSecret.startsWith('sk_dev_');
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 {
@@ -11,7 +11,7 @@ export interface DLQPayload {
11
11
  timestamp?: number;
12
12
  }
13
13
  export interface ReboundConfig {
14
- apiKey: string;
14
+ projectSecret: string;
15
15
  endpoint?: string;
16
16
  timeout?: number;
17
17
  }
package/package.json CHANGED
@@ -1,18 +1,23 @@
1
- {
2
- "name": "@rebound-dlq/node",
3
- "version": "0.2.3",
4
- "main": "dist/index.js",
5
- "types": "dist/index.d.ts",
6
- "files": ["dist"],
7
- "scripts": {
8
- "build": "tsc",
9
- "dev": "tsc --watch",
10
- "json-server": "npx json-server --watch mock-server.json --port 3005",
11
- "test:wrapper": "npx ts-node examples/wrapper-example.ts"
12
- },
13
- "devDependencies": {
14
- "@types/node": "^20.0.0",
15
- "json-server": "^1.0.0-beta.3",
16
- "typescript": "^5.0.0"
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
+ }