@allanfsouza/aether-sdk 2.4.7 → 2.4.10
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/dist/database.js +16 -6
- package/dist/http-client.d.ts +24 -6
- package/dist/http-client.js +154 -28
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/src/database.ts +21 -7
- package/src/http-client.ts +221 -32
- package/src/index.ts +8 -0
package/dist/database.js
CHANGED
|
@@ -199,7 +199,6 @@ export class CollectionReference {
|
|
|
199
199
|
console.warn("[SDK] Realtime falhou: Token ou ProjectId ausentes.");
|
|
200
200
|
return () => { };
|
|
201
201
|
}
|
|
202
|
-
// URL correta de subscribe
|
|
203
202
|
const url = `${this.wsUrl}/v1/db/subscribe/${this.collectionName}?token=${token}&projectId=${projectId}`;
|
|
204
203
|
let ws = null;
|
|
205
204
|
try {
|
|
@@ -207,7 +206,7 @@ export class CollectionReference {
|
|
|
207
206
|
if (!ws)
|
|
208
207
|
return () => { };
|
|
209
208
|
ws.onopen = () => {
|
|
210
|
-
|
|
209
|
+
console.log(`[SDK] Realtime conectado: ${this.collectionName}`);
|
|
211
210
|
};
|
|
212
211
|
ws.onmessage = (event) => {
|
|
213
212
|
try {
|
|
@@ -215,16 +214,27 @@ export class CollectionReference {
|
|
|
215
214
|
if (raw === "pong")
|
|
216
215
|
return;
|
|
217
216
|
const payload = JSON.parse(raw);
|
|
218
|
-
|
|
217
|
+
console.log(`[SDK] Evento recebido:`, payload.action, payload.data?.id);
|
|
218
|
+
// [FIX] Mapeia 'insert' do Postgres para 'create' do SDK
|
|
219
|
+
let action = payload.action;
|
|
220
|
+
if (payload.action === 'insert') {
|
|
221
|
+
action = 'create';
|
|
222
|
+
}
|
|
223
|
+
callback(action, payload.data);
|
|
219
224
|
}
|
|
220
225
|
catch (e) {
|
|
221
|
-
|
|
226
|
+
console.error('[SDK] Erro ao parsear evento:', e);
|
|
222
227
|
}
|
|
223
228
|
};
|
|
229
|
+
ws.onerror = (err) => {
|
|
230
|
+
console.error('[SDK] WebSocket erro:', err);
|
|
231
|
+
};
|
|
232
|
+
ws.onclose = () => {
|
|
233
|
+
console.log(`[SDK] Realtime desconectado: ${this.collectionName}`);
|
|
234
|
+
};
|
|
224
235
|
// Heartbeat
|
|
225
236
|
const pingInterval = setInterval(() => {
|
|
226
|
-
|
|
227
|
-
if (ws && ws.readyState === 1) { // 1 = OPEN
|
|
237
|
+
if (ws && ws.readyState === 1) {
|
|
228
238
|
ws.send("ping");
|
|
229
239
|
}
|
|
230
240
|
}, 30000);
|
package/dist/http-client.d.ts
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
|
-
import { type AxiosInstance } from "axios";
|
|
1
|
+
import { type AxiosInstance, type AxiosError, type AxiosRequestConfig } from "axios";
|
|
2
2
|
import type { PlataformaClient } from "./index.js";
|
|
3
|
+
export interface RetryConfig {
|
|
4
|
+
/** Número máximo de tentativas (padrão: 3) */
|
|
5
|
+
maxRetries: number;
|
|
6
|
+
/** Delay base em ms para exponential backoff (padrão: 1000) */
|
|
7
|
+
baseDelay: number;
|
|
8
|
+
/** Delay máximo em ms (padrão: 30000) */
|
|
9
|
+
maxDelay: number;
|
|
10
|
+
/** Status codes que devem ser retried (padrão: [408, 429, 500, 502, 503, 504]) */
|
|
11
|
+
retryableStatuses: number[];
|
|
12
|
+
/** Métodos HTTP que podem ser retried (padrão: GET, HEAD, OPTIONS, PUT, DELETE) */
|
|
13
|
+
retryableMethods: string[];
|
|
14
|
+
/** Habilita retry para erros de rede (padrão: true) */
|
|
15
|
+
retryOnNetworkError: boolean;
|
|
16
|
+
/** Callback chamado antes de cada retry */
|
|
17
|
+
onRetry?: (retryCount: number, error: AxiosError, config: AxiosRequestConfig) => void;
|
|
18
|
+
}
|
|
19
|
+
export declare const DEFAULT_RETRY_CONFIG: RetryConfig;
|
|
3
20
|
/**
|
|
4
|
-
* Cria uma instância do Axios pré-configurada
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
21
|
+
* Cria uma instância do Axios pré-configurada com:
|
|
22
|
+
* - Injeção automática de headers (Auth, Project)
|
|
23
|
+
* - Refresh automático de token (401)
|
|
24
|
+
* - Retry automático com exponential backoff
|
|
25
|
+
* - Tratamento de erros padronizado
|
|
8
26
|
*/
|
|
9
|
-
export declare function createHttpClient(client: PlataformaClient): AxiosInstance;
|
|
27
|
+
export declare function createHttpClient(client: PlataformaClient, retryConfig?: Partial<RetryConfig>): AxiosInstance;
|
package/dist/http-client.js
CHANGED
|
@@ -1,13 +1,88 @@
|
|
|
1
1
|
// src/http-client.ts
|
|
2
|
+
// [MELHORIA] SDK com Retry Automático e Exponential Backoff
|
|
3
|
+
// Data: 09/12/2025
|
|
2
4
|
import axios from "axios";
|
|
3
5
|
import { handleAxiosError } from "./errors.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
export const DEFAULT_RETRY_CONFIG = {
|
|
7
|
+
maxRetries: 3,
|
|
8
|
+
baseDelay: 1000,
|
|
9
|
+
maxDelay: 30000,
|
|
10
|
+
retryableStatuses: [408, 429, 500, 502, 503, 504],
|
|
11
|
+
retryableMethods: ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"],
|
|
12
|
+
retryOnNetworkError: true,
|
|
13
|
+
};
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// HELPERS
|
|
16
|
+
// =============================================================================
|
|
17
|
+
/**
|
|
18
|
+
* Calcula o delay com exponential backoff + jitter
|
|
19
|
+
*/
|
|
20
|
+
function calculateDelay(retryCount, config) {
|
|
21
|
+
// Exponential: baseDelay * 2^retryCount
|
|
22
|
+
const exponentialDelay = config.baseDelay * Math.pow(2, retryCount);
|
|
23
|
+
// Adiciona jitter (±25%) para evitar thundering herd
|
|
24
|
+
const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
|
|
25
|
+
// Limita ao maxDelay
|
|
26
|
+
return Math.min(exponentialDelay + jitter, config.maxDelay);
|
|
27
|
+
}
|
|
8
28
|
/**
|
|
9
|
-
*
|
|
29
|
+
* Verifica se o erro é retryable
|
|
10
30
|
*/
|
|
31
|
+
function isRetryable(error, config) {
|
|
32
|
+
// Erro de rede (sem response)
|
|
33
|
+
if (!error.response && config.retryOnNetworkError) {
|
|
34
|
+
// Verifica se é erro de rede real (não cancelamento)
|
|
35
|
+
if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT" ||
|
|
36
|
+
error.code === "ENOTFOUND" || error.code === "ENETUNREACH" ||
|
|
37
|
+
error.message === "Network Error") {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Verifica status code
|
|
42
|
+
const status = error.response?.status;
|
|
43
|
+
if (status && config.retryableStatuses.includes(status)) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Verifica se o método HTTP pode ser retried
|
|
50
|
+
*/
|
|
51
|
+
function isMethodRetryable(method, config) {
|
|
52
|
+
if (!method)
|
|
53
|
+
return false;
|
|
54
|
+
return config.retryableMethods.includes(method.toUpperCase());
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Extrai Retry-After header (em ms)
|
|
58
|
+
*/
|
|
59
|
+
function getRetryAfterMs(error) {
|
|
60
|
+
const retryAfter = error.response?.headers?.["retry-after"];
|
|
61
|
+
if (!retryAfter)
|
|
62
|
+
return null;
|
|
63
|
+
// Se for número, é segundos
|
|
64
|
+
const seconds = parseInt(retryAfter, 10);
|
|
65
|
+
if (!isNaN(seconds)) {
|
|
66
|
+
return seconds * 1000;
|
|
67
|
+
}
|
|
68
|
+
// Se for data HTTP, calcula diferença
|
|
69
|
+
const date = new Date(retryAfter);
|
|
70
|
+
if (!isNaN(date.getTime())) {
|
|
71
|
+
return Math.max(0, date.getTime() - Date.now());
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Aguarda um tempo determinado
|
|
77
|
+
*/
|
|
78
|
+
function sleep(ms) {
|
|
79
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
80
|
+
}
|
|
81
|
+
// =============================================================================
|
|
82
|
+
// FLAGS GLOBAIS (Refresh Token)
|
|
83
|
+
// =============================================================================
|
|
84
|
+
let isRefreshing = false;
|
|
85
|
+
let failedQueue = [];
|
|
11
86
|
const processQueue = (error, token = null) => {
|
|
12
87
|
failedQueue.forEach((prom) => {
|
|
13
88
|
if (error) {
|
|
@@ -19,54 +94,111 @@ const processQueue = (error, token = null) => {
|
|
|
19
94
|
});
|
|
20
95
|
failedQueue = [];
|
|
21
96
|
};
|
|
97
|
+
// =============================================================================
|
|
98
|
+
// CRIAR HTTP CLIENT
|
|
99
|
+
// =============================================================================
|
|
22
100
|
/**
|
|
23
|
-
* Cria uma instância do Axios pré-configurada
|
|
24
|
-
* -
|
|
25
|
-
* -
|
|
26
|
-
* -
|
|
101
|
+
* Cria uma instância do Axios pré-configurada com:
|
|
102
|
+
* - Injeção automática de headers (Auth, Project)
|
|
103
|
+
* - Refresh automático de token (401)
|
|
104
|
+
* - Retry automático com exponential backoff
|
|
105
|
+
* - Tratamento de erros padronizado
|
|
27
106
|
*/
|
|
28
|
-
export function createHttpClient(client) {
|
|
107
|
+
export function createHttpClient(client, retryConfig = {}) {
|
|
108
|
+
const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig };
|
|
29
109
|
const http = axios.create({
|
|
30
110
|
baseURL: `${client.apiUrl}/v1`,
|
|
111
|
+
timeout: 30000, // 30 segundos
|
|
31
112
|
headers: {
|
|
32
113
|
"Content-Type": "application/json",
|
|
33
114
|
},
|
|
34
115
|
});
|
|
35
116
|
// ===========================================================================
|
|
36
117
|
// INTERCEPTOR DE REQUEST
|
|
37
|
-
// Injeta token e
|
|
118
|
+
// Injeta token, projectId e API Key em todas as requisições
|
|
38
119
|
// ===========================================================================
|
|
39
|
-
http.interceptors.request.use((
|
|
120
|
+
http.interceptors.request.use((reqConfig) => {
|
|
40
121
|
const token = client.getToken();
|
|
41
122
|
if (token) {
|
|
42
|
-
|
|
123
|
+
reqConfig.headers.Authorization = `Bearer ${token}`;
|
|
43
124
|
}
|
|
44
125
|
if (client.projectId) {
|
|
45
|
-
|
|
126
|
+
reqConfig.headers["X-Project-ID"] = client.projectId;
|
|
46
127
|
}
|
|
47
|
-
|
|
128
|
+
// [NOVO] Envia API Key para autenticação de operações de banco de dados
|
|
129
|
+
if (client.serviceApiKey) {
|
|
130
|
+
reqConfig.headers["X-API-Key"] = client.serviceApiKey;
|
|
131
|
+
}
|
|
132
|
+
// Inicializa contador de retry
|
|
133
|
+
if (reqConfig._retryCount === undefined) {
|
|
134
|
+
reqConfig._retryCount = 0;
|
|
135
|
+
}
|
|
136
|
+
return reqConfig;
|
|
48
137
|
});
|
|
49
138
|
// ===========================================================================
|
|
50
139
|
// INTERCEPTOR DE RESPONSE
|
|
51
|
-
//
|
|
140
|
+
// 1. Retry automático para erros de rede/servidor
|
|
141
|
+
// 2. Refresh automático de token (401)
|
|
52
142
|
// ===========================================================================
|
|
53
143
|
http.interceptors.response.use((response) => response, async (error) => {
|
|
54
144
|
const originalRequest = error.config;
|
|
55
|
-
|
|
145
|
+
if (!originalRequest) {
|
|
146
|
+
return handleAxiosError(error);
|
|
147
|
+
}
|
|
148
|
+
// -----------------------------------------------------------------
|
|
149
|
+
// PARTE 1: RETRY AUTOMÁTICO (erros de rede e servidor)
|
|
150
|
+
// -----------------------------------------------------------------
|
|
151
|
+
const retryCount = originalRequest._retryCount || 0;
|
|
152
|
+
const canRetry = retryCount < config.maxRetries;
|
|
153
|
+
const isRetryableError = isRetryable(error, config);
|
|
154
|
+
const isRetryableMethod = isMethodRetryable(originalRequest.method, config);
|
|
155
|
+
// Não faz retry para 401 (tratado pelo refresh token)
|
|
156
|
+
const is401 = error.response?.status === 401;
|
|
157
|
+
if (canRetry && isRetryableError && isRetryableMethod && !is401) {
|
|
158
|
+
originalRequest._retryCount = retryCount + 1;
|
|
159
|
+
// Calcula delay (respeita Retry-After se presente)
|
|
160
|
+
const delay = getRetryAfterMs(error) || calculateDelay(retryCount, config);
|
|
161
|
+
// Callback de retry (para logging/métricas)
|
|
162
|
+
if (config.onRetry) {
|
|
163
|
+
config.onRetry(originalRequest._retryCount, error, originalRequest);
|
|
164
|
+
}
|
|
165
|
+
// Emite evento para monitoramento
|
|
166
|
+
if (typeof window !== "undefined") {
|
|
167
|
+
window.dispatchEvent(new CustomEvent("aether:retry", {
|
|
168
|
+
detail: {
|
|
169
|
+
attempt: originalRequest._retryCount,
|
|
170
|
+
maxRetries: config.maxRetries,
|
|
171
|
+
delay,
|
|
172
|
+
url: originalRequest.url,
|
|
173
|
+
method: originalRequest.method,
|
|
174
|
+
status: error.response?.status,
|
|
175
|
+
error: error.message,
|
|
176
|
+
}
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
// Log em desenvolvimento
|
|
180
|
+
if (process.env.NODE_ENV === "development") {
|
|
181
|
+
console.log(`[Aether SDK] Retry ${originalRequest._retryCount}/${config.maxRetries}`, `em ${delay}ms para ${originalRequest.method} ${originalRequest.url}`, `(${error.response?.status || error.message})`);
|
|
182
|
+
}
|
|
183
|
+
// Aguarda e refaz a requisição
|
|
184
|
+
await sleep(delay);
|
|
185
|
+
return http(originalRequest);
|
|
186
|
+
}
|
|
187
|
+
// -----------------------------------------------------------------
|
|
188
|
+
// PARTE 2: REFRESH TOKEN (erro 401)
|
|
189
|
+
// -----------------------------------------------------------------
|
|
56
190
|
const isUnauthorized = error.response?.status === 401;
|
|
57
|
-
const isRetry = originalRequest
|
|
58
|
-
const isAuthRoute = originalRequest
|
|
191
|
+
const isRetry = originalRequest._retry;
|
|
192
|
+
const isAuthRoute = originalRequest.url?.includes("/auth/");
|
|
59
193
|
// Não tenta refresh se:
|
|
60
194
|
// - Não é 401
|
|
61
|
-
// - Já é um retry
|
|
195
|
+
// - Já é um retry de refresh
|
|
62
196
|
// - É uma rota de auth (login, register, refresh)
|
|
63
|
-
// - Não tem refresh token disponível
|
|
64
197
|
if (!isUnauthorized || isRetry || isAuthRoute) {
|
|
65
198
|
return handleAxiosError(error);
|
|
66
199
|
}
|
|
67
200
|
const refreshToken = client.getRefreshToken();
|
|
68
201
|
if (!refreshToken) {
|
|
69
|
-
// Sem refresh token, limpa sessão e propaga erro
|
|
70
202
|
client.clearSession();
|
|
71
203
|
return handleAxiosError(error);
|
|
72
204
|
}
|
|
@@ -85,26 +217,20 @@ export function createHttpClient(client) {
|
|
|
85
217
|
originalRequest._retry = true;
|
|
86
218
|
isRefreshing = true;
|
|
87
219
|
try {
|
|
88
|
-
// Chama endpoint de refresh diretamente (sem interceptor)
|
|
89
220
|
const { data } = await axios.post(`${client.apiUrl}/v1/auth/refresh`, { refreshToken }, { headers: { "Content-Type": "application/json" } });
|
|
90
221
|
const newAccessToken = data.accessToken;
|
|
91
222
|
const newRefreshToken = data.refreshToken;
|
|
92
|
-
// Atualiza tokens no client (persiste no localStorage)
|
|
93
223
|
client.setToken(newAccessToken);
|
|
94
224
|
if (newRefreshToken) {
|
|
95
225
|
client.setRefreshToken(newRefreshToken);
|
|
96
226
|
}
|
|
97
|
-
// Processa fila de requisições pendentes
|
|
98
227
|
processQueue(null, newAccessToken);
|
|
99
|
-
// Refaz a requisição original com novo token
|
|
100
228
|
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
|
101
229
|
return http(originalRequest);
|
|
102
230
|
}
|
|
103
231
|
catch (refreshError) {
|
|
104
|
-
// Refresh falhou - sessão expirada
|
|
105
232
|
processQueue(refreshError, null);
|
|
106
233
|
client.clearSession();
|
|
107
|
-
// Emite evento de sessão expirada (se houver listener)
|
|
108
234
|
if (typeof window !== "undefined") {
|
|
109
235
|
window.dispatchEvent(new CustomEvent("aether:session-expired"));
|
|
110
236
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,11 @@ export type ClientConfig = {
|
|
|
13
13
|
baseUrl?: string;
|
|
14
14
|
projectId?: string;
|
|
15
15
|
apiKey?: string;
|
|
16
|
+
/**
|
|
17
|
+
* [NOVO] API Key de serviço para autenticação em operações de banco de dados.
|
|
18
|
+
* Necessário para apps client-side que usam tenant auth.
|
|
19
|
+
*/
|
|
20
|
+
serviceApiKey?: string;
|
|
16
21
|
/**
|
|
17
22
|
* Habilita persistência automática de sessão no localStorage.
|
|
18
23
|
* Padrão: true em browsers, false em Node.js/SSR.
|
|
@@ -29,6 +34,7 @@ export declare class PlataformaClient {
|
|
|
29
34
|
database: DatabaseModule;
|
|
30
35
|
apiUrl: string;
|
|
31
36
|
projectId: string;
|
|
37
|
+
serviceApiKey: string | null;
|
|
32
38
|
http: AxiosInstance;
|
|
33
39
|
private _token;
|
|
34
40
|
private _persistSession;
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,7 @@ function isBrowser() {
|
|
|
24
24
|
}
|
|
25
25
|
export class PlataformaClient {
|
|
26
26
|
constructor(config) {
|
|
27
|
+
this.serviceApiKey = null;
|
|
27
28
|
this._token = null;
|
|
28
29
|
// Resolve URL (prioridade para baseUrl se existir, senão apiUrl)
|
|
29
30
|
const url = config.baseUrl || config.apiUrl;
|
|
@@ -33,6 +34,7 @@ export class PlataformaClient {
|
|
|
33
34
|
}
|
|
34
35
|
this.apiUrl = url.replace(/\/+$/, "");
|
|
35
36
|
this.projectId = project;
|
|
37
|
+
this.serviceApiKey = config.serviceApiKey || null;
|
|
36
38
|
// Persistência habilitada por padrão apenas em browsers
|
|
37
39
|
this._persistSession = config.persistSession ?? isBrowser();
|
|
38
40
|
// Restaura sessão salva ANTES de criar o httpClient
|
package/package.json
CHANGED
package/src/database.ts
CHANGED
|
@@ -285,7 +285,6 @@ export class CollectionReference<T> {
|
|
|
285
285
|
return () => { };
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
// URL correta de subscribe
|
|
289
288
|
const url = `${this.wsUrl}/v1/db/subscribe/${this.collectionName}?token=${token}&projectId=${projectId}`;
|
|
290
289
|
|
|
291
290
|
let ws: WebSocket | null = null;
|
|
@@ -296,7 +295,7 @@ export class CollectionReference<T> {
|
|
|
296
295
|
if (!ws) return () => { };
|
|
297
296
|
|
|
298
297
|
ws.onopen = () => {
|
|
299
|
-
|
|
298
|
+
console.log(`[SDK] Realtime conectado: ${this.collectionName}`);
|
|
300
299
|
};
|
|
301
300
|
|
|
302
301
|
ws.onmessage = (event: any) => {
|
|
@@ -304,17 +303,32 @@ export class CollectionReference<T> {
|
|
|
304
303
|
const raw = event.data?.toString() || event.toString();
|
|
305
304
|
if (raw === "pong") return;
|
|
306
305
|
|
|
307
|
-
const payload = JSON.parse(raw)
|
|
308
|
-
|
|
306
|
+
const payload = JSON.parse(raw);
|
|
307
|
+
console.log(`[SDK] Evento recebido:`, payload.action, payload.data?.id);
|
|
308
|
+
|
|
309
|
+
// [FIX] Mapeia 'insert' do Postgres para 'create' do SDK
|
|
310
|
+
let action: "create" | "update" | "delete" = payload.action;
|
|
311
|
+
if (payload.action === 'insert') {
|
|
312
|
+
action = 'create';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
callback(action, payload.data);
|
|
309
316
|
} catch (e) {
|
|
310
|
-
|
|
317
|
+
console.error('[SDK] Erro ao parsear evento:', e);
|
|
311
318
|
}
|
|
312
319
|
};
|
|
313
320
|
|
|
321
|
+
ws.onerror = (err) => {
|
|
322
|
+
console.error('[SDK] WebSocket erro:', err);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
ws.onclose = () => {
|
|
326
|
+
console.log(`[SDK] Realtime desconectado: ${this.collectionName}`);
|
|
327
|
+
};
|
|
328
|
+
|
|
314
329
|
// Heartbeat
|
|
315
330
|
const pingInterval = setInterval(() => {
|
|
316
|
-
|
|
317
|
-
if (ws && ws.readyState === 1) { // 1 = OPEN
|
|
331
|
+
if (ws && ws.readyState === 1) {
|
|
318
332
|
ws.send("ping");
|
|
319
333
|
}
|
|
320
334
|
}, 30000);
|
package/src/http-client.ts
CHANGED
|
@@ -1,24 +1,135 @@
|
|
|
1
1
|
// src/http-client.ts
|
|
2
|
+
// [MELHORIA] SDK com Retry Automático e Exponential Backoff
|
|
3
|
+
// Data: 09/12/2025
|
|
4
|
+
|
|
2
5
|
import axios, {
|
|
3
6
|
type AxiosInstance,
|
|
4
7
|
type AxiosError,
|
|
8
|
+
type AxiosRequestConfig,
|
|
5
9
|
type InternalAxiosRequestConfig,
|
|
6
10
|
} from "axios";
|
|
7
11
|
import type { PlataformaClient } from "./index.js";
|
|
8
12
|
import { handleAxiosError } from "./errors.js";
|
|
9
13
|
|
|
10
|
-
//
|
|
11
|
-
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// CONFIGURAÇÕES DE RETRY
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
export interface RetryConfig {
|
|
19
|
+
/** Número máximo de tentativas (padrão: 3) */
|
|
20
|
+
maxRetries: number;
|
|
21
|
+
/** Delay base em ms para exponential backoff (padrão: 1000) */
|
|
22
|
+
baseDelay: number;
|
|
23
|
+
/** Delay máximo em ms (padrão: 30000) */
|
|
24
|
+
maxDelay: number;
|
|
25
|
+
/** Status codes que devem ser retried (padrão: [408, 429, 500, 502, 503, 504]) */
|
|
26
|
+
retryableStatuses: number[];
|
|
27
|
+
/** Métodos HTTP que podem ser retried (padrão: GET, HEAD, OPTIONS, PUT, DELETE) */
|
|
28
|
+
retryableMethods: string[];
|
|
29
|
+
/** Habilita retry para erros de rede (padrão: true) */
|
|
30
|
+
retryOnNetworkError: boolean;
|
|
31
|
+
/** Callback chamado antes de cada retry */
|
|
32
|
+
onRetry?: (retryCount: number, error: AxiosError, config: AxiosRequestConfig) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
|
36
|
+
maxRetries: 3,
|
|
37
|
+
baseDelay: 1000,
|
|
38
|
+
maxDelay: 30000,
|
|
39
|
+
retryableStatuses: [408, 429, 500, 502, 503, 504],
|
|
40
|
+
retryableMethods: ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"],
|
|
41
|
+
retryOnNetworkError: true,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// =============================================================================
|
|
45
|
+
// HELPERS
|
|
46
|
+
// =============================================================================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Calcula o delay com exponential backoff + jitter
|
|
50
|
+
*/
|
|
51
|
+
function calculateDelay(retryCount: number, config: RetryConfig): number {
|
|
52
|
+
// Exponential: baseDelay * 2^retryCount
|
|
53
|
+
const exponentialDelay = config.baseDelay * Math.pow(2, retryCount);
|
|
54
|
+
|
|
55
|
+
// Adiciona jitter (±25%) para evitar thundering herd
|
|
56
|
+
const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
|
|
57
|
+
|
|
58
|
+
// Limita ao maxDelay
|
|
59
|
+
return Math.min(exponentialDelay + jitter, config.maxDelay);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Verifica se o erro é retryable
|
|
64
|
+
*/
|
|
65
|
+
function isRetryable(error: AxiosError, config: RetryConfig): boolean {
|
|
66
|
+
// Erro de rede (sem response)
|
|
67
|
+
if (!error.response && config.retryOnNetworkError) {
|
|
68
|
+
// Verifica se é erro de rede real (não cancelamento)
|
|
69
|
+
if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT" ||
|
|
70
|
+
error.code === "ENOTFOUND" || error.code === "ENETUNREACH" ||
|
|
71
|
+
error.message === "Network Error") {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Verifica status code
|
|
77
|
+
const status = error.response?.status;
|
|
78
|
+
if (status && config.retryableStatuses.includes(status)) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Verifica se o método HTTP pode ser retried
|
|
87
|
+
*/
|
|
88
|
+
function isMethodRetryable(method: string | undefined, config: RetryConfig): boolean {
|
|
89
|
+
if (!method) return false;
|
|
90
|
+
return config.retryableMethods.includes(method.toUpperCase());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extrai Retry-After header (em ms)
|
|
95
|
+
*/
|
|
96
|
+
function getRetryAfterMs(error: AxiosError): number | null {
|
|
97
|
+
const retryAfter = error.response?.headers?.["retry-after"];
|
|
98
|
+
|
|
99
|
+
if (!retryAfter) return null;
|
|
100
|
+
|
|
101
|
+
// Se for número, é segundos
|
|
102
|
+
const seconds = parseInt(retryAfter, 10);
|
|
103
|
+
if (!isNaN(seconds)) {
|
|
104
|
+
return seconds * 1000;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Se for data HTTP, calcula diferença
|
|
108
|
+
const date = new Date(retryAfter);
|
|
109
|
+
if (!isNaN(date.getTime())) {
|
|
110
|
+
return Math.max(0, date.getTime() - Date.now());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Aguarda um tempo determinado
|
|
118
|
+
*/
|
|
119
|
+
function sleep(ms: number): Promise<void> {
|
|
120
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
121
|
+
}
|
|
12
122
|
|
|
13
|
-
//
|
|
123
|
+
// =============================================================================
|
|
124
|
+
// FLAGS GLOBAIS (Refresh Token)
|
|
125
|
+
// =============================================================================
|
|
126
|
+
|
|
127
|
+
let isRefreshing = false;
|
|
14
128
|
let failedQueue: Array<{
|
|
15
129
|
resolve: (token: string) => void;
|
|
16
130
|
reject: (error: any) => void;
|
|
17
131
|
}> = [];
|
|
18
132
|
|
|
19
|
-
/**
|
|
20
|
-
* Processa a fila de requisições após o refresh.
|
|
21
|
-
*/
|
|
22
133
|
const processQueue = (error: any, token: string | null = null) => {
|
|
23
134
|
failedQueue.forEach((prom) => {
|
|
24
135
|
if (error) {
|
|
@@ -30,15 +141,35 @@ const processQueue = (error: any, token: string | null = null) => {
|
|
|
30
141
|
failedQueue = [];
|
|
31
142
|
};
|
|
32
143
|
|
|
144
|
+
// =============================================================================
|
|
145
|
+
// EXTENDED CONFIG TYPE
|
|
146
|
+
// =============================================================================
|
|
147
|
+
|
|
148
|
+
interface ExtendedAxiosRequestConfig extends InternalAxiosRequestConfig {
|
|
149
|
+
_retry?: boolean;
|
|
150
|
+
_retryCount?: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// =============================================================================
|
|
154
|
+
// CRIAR HTTP CLIENT
|
|
155
|
+
// =============================================================================
|
|
156
|
+
|
|
33
157
|
/**
|
|
34
|
-
* Cria uma instância do Axios pré-configurada
|
|
35
|
-
* -
|
|
36
|
-
* -
|
|
37
|
-
* -
|
|
158
|
+
* Cria uma instância do Axios pré-configurada com:
|
|
159
|
+
* - Injeção automática de headers (Auth, Project)
|
|
160
|
+
* - Refresh automático de token (401)
|
|
161
|
+
* - Retry automático com exponential backoff
|
|
162
|
+
* - Tratamento de erros padronizado
|
|
38
163
|
*/
|
|
39
|
-
export function createHttpClient(
|
|
164
|
+
export function createHttpClient(
|
|
165
|
+
client: PlataformaClient,
|
|
166
|
+
retryConfig: Partial<RetryConfig> = {}
|
|
167
|
+
): AxiosInstance {
|
|
168
|
+
const config: RetryConfig = { ...DEFAULT_RETRY_CONFIG, ...retryConfig };
|
|
169
|
+
|
|
40
170
|
const http = axios.create({
|
|
41
171
|
baseURL: `${client.apiUrl}/v1`,
|
|
172
|
+
timeout: 30000, // 30 segundos
|
|
42
173
|
headers: {
|
|
43
174
|
"Content-Type": "application/json",
|
|
44
175
|
},
|
|
@@ -46,49 +177,113 @@ export function createHttpClient(client: PlataformaClient): AxiosInstance {
|
|
|
46
177
|
|
|
47
178
|
// ===========================================================================
|
|
48
179
|
// INTERCEPTOR DE REQUEST
|
|
49
|
-
// Injeta token e
|
|
180
|
+
// Injeta token, projectId e API Key em todas as requisições
|
|
50
181
|
// ===========================================================================
|
|
51
|
-
http.interceptors.request.use((
|
|
182
|
+
http.interceptors.request.use((reqConfig) => {
|
|
52
183
|
const token = client.getToken();
|
|
53
184
|
if (token) {
|
|
54
|
-
|
|
185
|
+
reqConfig.headers.Authorization = `Bearer ${token}`;
|
|
55
186
|
}
|
|
56
187
|
|
|
57
188
|
if (client.projectId) {
|
|
58
|
-
|
|
189
|
+
reqConfig.headers["X-Project-ID"] = client.projectId;
|
|
59
190
|
}
|
|
60
191
|
|
|
61
|
-
|
|
192
|
+
// [NOVO] Envia API Key para autenticação de operações de banco de dados
|
|
193
|
+
if (client.serviceApiKey) {
|
|
194
|
+
reqConfig.headers["X-API-Key"] = client.serviceApiKey;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Inicializa contador de retry
|
|
198
|
+
if ((reqConfig as ExtendedAxiosRequestConfig)._retryCount === undefined) {
|
|
199
|
+
(reqConfig as ExtendedAxiosRequestConfig)._retryCount = 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return reqConfig;
|
|
62
203
|
});
|
|
63
204
|
|
|
64
205
|
// ===========================================================================
|
|
65
206
|
// INTERCEPTOR DE RESPONSE
|
|
66
|
-
//
|
|
207
|
+
// 1. Retry automático para erros de rede/servidor
|
|
208
|
+
// 2. Refresh automático de token (401)
|
|
67
209
|
// ===========================================================================
|
|
68
210
|
http.interceptors.response.use(
|
|
69
211
|
(response) => response,
|
|
70
212
|
async (error: AxiosError) => {
|
|
71
|
-
const originalRequest = error.config as
|
|
72
|
-
|
|
73
|
-
|
|
213
|
+
const originalRequest = error.config as ExtendedAxiosRequestConfig;
|
|
214
|
+
|
|
215
|
+
if (!originalRequest) {
|
|
216
|
+
return handleAxiosError(error);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// -----------------------------------------------------------------
|
|
220
|
+
// PARTE 1: RETRY AUTOMÁTICO (erros de rede e servidor)
|
|
221
|
+
// -----------------------------------------------------------------
|
|
222
|
+
const retryCount = originalRequest._retryCount || 0;
|
|
223
|
+
const canRetry = retryCount < config.maxRetries;
|
|
224
|
+
const isRetryableError = isRetryable(error, config);
|
|
225
|
+
const isRetryableMethod = isMethodRetryable(originalRequest.method, config);
|
|
226
|
+
|
|
227
|
+
// Não faz retry para 401 (tratado pelo refresh token)
|
|
228
|
+
const is401 = error.response?.status === 401;
|
|
229
|
+
|
|
230
|
+
if (canRetry && isRetryableError && isRetryableMethod && !is401) {
|
|
231
|
+
originalRequest._retryCount = retryCount + 1;
|
|
232
|
+
|
|
233
|
+
// Calcula delay (respeita Retry-After se presente)
|
|
234
|
+
const delay = getRetryAfterMs(error) || calculateDelay(retryCount, config);
|
|
235
|
+
|
|
236
|
+
// Callback de retry (para logging/métricas)
|
|
237
|
+
if (config.onRetry) {
|
|
238
|
+
config.onRetry(originalRequest._retryCount, error, originalRequest);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Emite evento para monitoramento
|
|
242
|
+
if (typeof window !== "undefined") {
|
|
243
|
+
window.dispatchEvent(new CustomEvent("aether:retry", {
|
|
244
|
+
detail: {
|
|
245
|
+
attempt: originalRequest._retryCount,
|
|
246
|
+
maxRetries: config.maxRetries,
|
|
247
|
+
delay,
|
|
248
|
+
url: originalRequest.url,
|
|
249
|
+
method: originalRequest.method,
|
|
250
|
+
status: error.response?.status,
|
|
251
|
+
error: error.message,
|
|
252
|
+
}
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Log em desenvolvimento
|
|
257
|
+
if (process.env.NODE_ENV === "development") {
|
|
258
|
+
console.log(
|
|
259
|
+
`[Aether SDK] Retry ${originalRequest._retryCount}/${config.maxRetries}`,
|
|
260
|
+
`em ${delay}ms para ${originalRequest.method} ${originalRequest.url}`,
|
|
261
|
+
`(${error.response?.status || error.message})`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Aguarda e refaz a requisição
|
|
266
|
+
await sleep(delay);
|
|
267
|
+
return http(originalRequest);
|
|
268
|
+
}
|
|
74
269
|
|
|
75
|
-
//
|
|
270
|
+
// -----------------------------------------------------------------
|
|
271
|
+
// PARTE 2: REFRESH TOKEN (erro 401)
|
|
272
|
+
// -----------------------------------------------------------------
|
|
76
273
|
const isUnauthorized = error.response?.status === 401;
|
|
77
|
-
const isRetry = originalRequest
|
|
78
|
-
const isAuthRoute = originalRequest
|
|
274
|
+
const isRetry = originalRequest._retry;
|
|
275
|
+
const isAuthRoute = originalRequest.url?.includes("/auth/");
|
|
79
276
|
|
|
80
277
|
// Não tenta refresh se:
|
|
81
278
|
// - Não é 401
|
|
82
|
-
// - Já é um retry
|
|
279
|
+
// - Já é um retry de refresh
|
|
83
280
|
// - É uma rota de auth (login, register, refresh)
|
|
84
|
-
// - Não tem refresh token disponível
|
|
85
281
|
if (!isUnauthorized || isRetry || isAuthRoute) {
|
|
86
282
|
return handleAxiosError(error);
|
|
87
283
|
}
|
|
88
284
|
|
|
89
285
|
const refreshToken = client.getRefreshToken();
|
|
90
286
|
if (!refreshToken) {
|
|
91
|
-
// Sem refresh token, limpa sessão e propaga erro
|
|
92
287
|
client.clearSession();
|
|
93
288
|
return handleAxiosError(error);
|
|
94
289
|
}
|
|
@@ -110,7 +305,6 @@ export function createHttpClient(client: PlataformaClient): AxiosInstance {
|
|
|
110
305
|
isRefreshing = true;
|
|
111
306
|
|
|
112
307
|
try {
|
|
113
|
-
// Chama endpoint de refresh diretamente (sem interceptor)
|
|
114
308
|
const { data } = await axios.post(
|
|
115
309
|
`${client.apiUrl}/v1/auth/refresh`,
|
|
116
310
|
{ refreshToken },
|
|
@@ -120,24 +314,19 @@ export function createHttpClient(client: PlataformaClient): AxiosInstance {
|
|
|
120
314
|
const newAccessToken = data.accessToken;
|
|
121
315
|
const newRefreshToken = data.refreshToken;
|
|
122
316
|
|
|
123
|
-
// Atualiza tokens no client (persiste no localStorage)
|
|
124
317
|
client.setToken(newAccessToken);
|
|
125
318
|
if (newRefreshToken) {
|
|
126
319
|
client.setRefreshToken(newRefreshToken);
|
|
127
320
|
}
|
|
128
321
|
|
|
129
|
-
// Processa fila de requisições pendentes
|
|
130
322
|
processQueue(null, newAccessToken);
|
|
131
323
|
|
|
132
|
-
// Refaz a requisição original com novo token
|
|
133
324
|
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
|
134
325
|
return http(originalRequest);
|
|
135
326
|
} catch (refreshError: any) {
|
|
136
|
-
// Refresh falhou - sessão expirada
|
|
137
327
|
processQueue(refreshError, null);
|
|
138
328
|
client.clearSession();
|
|
139
329
|
|
|
140
|
-
// Emite evento de sessão expirada (se houver listener)
|
|
141
330
|
if (typeof window !== "undefined") {
|
|
142
331
|
window.dispatchEvent(new CustomEvent("aether:session-expired"));
|
|
143
332
|
}
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,12 @@ export type ClientConfig = {
|
|
|
28
28
|
projectId?: string;
|
|
29
29
|
apiKey?: string;
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* [NOVO] API Key de serviço para autenticação em operações de banco de dados.
|
|
33
|
+
* Necessário para apps client-side que usam tenant auth.
|
|
34
|
+
*/
|
|
35
|
+
serviceApiKey?: string;
|
|
36
|
+
|
|
31
37
|
/**
|
|
32
38
|
* Habilita persistência automática de sessão no localStorage.
|
|
33
39
|
* Padrão: true em browsers, false em Node.js/SSR.
|
|
@@ -59,6 +65,7 @@ export class PlataformaClient {
|
|
|
59
65
|
|
|
60
66
|
public apiUrl: string;
|
|
61
67
|
public projectId: string;
|
|
68
|
+
public serviceApiKey: string | null = null;
|
|
62
69
|
|
|
63
70
|
public http: AxiosInstance;
|
|
64
71
|
|
|
@@ -76,6 +83,7 @@ export class PlataformaClient {
|
|
|
76
83
|
|
|
77
84
|
this.apiUrl = url.replace(/\/+$/, "");
|
|
78
85
|
this.projectId = project;
|
|
86
|
+
this.serviceApiKey = config.serviceApiKey || null;
|
|
79
87
|
|
|
80
88
|
// Persistência habilitada por padrão apenas em browsers
|
|
81
89
|
this._persistSession = config.persistSession ?? isBrowser();
|