Compare commits

...

2 Commits

Author SHA1 Message Date
borja 9883af5d02 feat: añade auto-ensure con reintentos y backoff en webhook
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
2 months ago
borja ecff2a5643 feat: añadir reintentos en fondo para webhook y tests
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
2 months ago

@ -9,22 +9,20 @@ import { MaintenanceService } from '../services/maintenance';
export async function startServices(_db: Database): Promise<void> { export async function startServices(_db: Database): Promise<void> {
// Exponer la DB globalmente vía locator para servicios que lo usen. // Exponer la DB globalmente vía locator para servicios que lo usen.
try { setDb(_db); } catch {} try { setDb(_db); } catch {}
await WebhookManager.registerWebhook();
// Add small delay to allow webhook to propagate // Iniciar aseguramiento automático del webhook en background (reintentos con backoff)
await new Promise(resolve => setTimeout(resolve, 1000)); try {
const isActive = await WebhookManager.verifyWebhook(); WebhookManager.startAutoEnsure();
if (!isActive) { } catch (e) {
console.error('❌ Webhook verification failed - retrying in 2 seconds...'); console.error('⚠️ Failed to start webhook auto-ensure loop:', e);
await new Promise(resolve => setTimeout(resolve, 2000));
const isActiveRetry = await WebhookManager.verifyWebhook();
if (!isActiveRetry) {
console.error('❌ Webhook verification failed after retry');
process.exit(1);
}
} }
// Initialize groups - critical for operation // Initialize groups - critical for operation (no abortar si falla)
try {
await GroupSyncService.checkInitialGroups(); await GroupSyncService.checkInitialGroups();
} catch (e) {
console.error('⚠️ Initial groups check failed (will rely on schedulers to recover):', e);
}
// Start groups scheduler (periodic sync of groups) // Start groups scheduler (periodic sync of groups)
try { try {
@ -73,6 +71,9 @@ export async function startServices(_db: Database): Promise<void> {
} }
export function stopServices(): void { export function stopServices(): void {
try {
WebhookManager.stopAutoEnsure();
} catch {}
try { try {
ResponseQueue.stopCleanupScheduler(); ResponseQueue.stopCleanupScheduler();
} catch {} } catch {}

@ -244,4 +244,96 @@ export class WebhookManager {
return false; return false;
} }
} }
// ===== Auto-ensure loop con reintentos y backoff =====
private static autoEnsureTimer: any = null;
private static autoEnsureActive: boolean = false;
private static autoEnsureAttempts: number = 0;
private static getRetryConfig() {
const n = (v: any, d: number) => {
const x = Number(v);
return Number.isFinite(x) && x >= 0 ? x : d;
};
const initial = n(process.env.WEBHOOK_RETRY_INITIAL_DELAY_MS, 1000);
const max = Math.max(initial, n(process.env.WEBHOOK_RETRY_MAX_DELAY_MS, 60000));
const degraded = Math.max(max, n(process.env.WEBHOOK_RETRY_DEGRADED_INTERVAL_MS, 300000));
const jitter = String(process.env.WEBHOOK_RETRY_JITTER || 'true').toLowerCase();
return {
initialDelayMs: initial,
maxDelayMs: max,
degradedIntervalMs: degraded,
jitter: ['true', '1', 'yes', 'on'].includes(jitter),
maxAttempts: Number.isFinite(Number(process.env.WEBHOOK_RETRY_MAX_ATTEMPTS))
? Math.max(0, Number(process.env.WEBHOOK_RETRY_MAX_ATTEMPTS))
: Infinity
};
}
private static jitter(val: number, enabled: boolean): number {
if (!enabled) return val;
const factor = 0.5 + Math.random(); // 0.5x - 1.5x
return Math.max(1, Math.floor(val * factor));
}
private static nextDelayMs(attempt: number, cfg: ReturnType<typeof this.getRetryConfig>): number {
// attempt 0 -> initial, 1 -> 2x, etc. capped at max, luego degradado cada degradedIntervalMs
const pow = Math.min(cfg.maxDelayMs, cfg.initialDelayMs * Math.pow(2, attempt));
return this.jitter(pow, cfg.jitter);
}
static startAutoEnsure(): void {
if (this.autoEnsureActive) {
return;
}
this.autoEnsureActive = true;
this.autoEnsureAttempts = 0;
const cfg = this.getRetryConfig();
const attemptOnce = async () => {
// Validación de config: si falta algo, abortar sin reintentos
try {
this.validateConfig();
} catch (e) {
console.error('❌ Webhook auto-ensure aborted due to invalid configuration:', e instanceof Error ? e.message : e);
this.stopAutoEnsure();
return;
}
try {
await this.registerWebhook();
const ok = await this.verifyWebhook();
if (ok) {
console.log('✅ Webhook is active');
this.stopAutoEnsure();
return;
}
throw new Error('verify returned false');
} catch (err) {
if (!this.autoEnsureActive) return; // pudo ser parado durante espera
const a = this.autoEnsureAttempts++;
const delay = a < 10 ? this.nextDelayMs(a, cfg) : cfg.degradedIntervalMs;
const msg = err instanceof Error ? `${err.message}` : String(err);
console.warn(`⚠️ Webhook ensure attempt #${a + 1} failed: ${msg}. Retrying in ${delay}ms`);
this.autoEnsureTimer = setTimeout(() => {
// Evitar múltiples overlapped
if (!this.autoEnsureActive) return;
attemptOnce();
}, delay);
}
};
// Disparar el primer intento sin esperar
attemptOnce();
}
static stopAutoEnsure(): void {
if (this.autoEnsureTimer) {
try { clearTimeout(this.autoEnsureTimer); } catch {}
this.autoEnsureTimer = null;
}
this.autoEnsureActive = false;
}
} }

@ -80,4 +80,74 @@ describe('WebhookManager', () => {
} }
}).toThrow(); }).toThrow();
}); });
test('should retry registration in background until success', async () => {
// Acelerar reintentos
process.env.WEBHOOK_RETRY_INITIAL_DELAY_MS = '10';
process.env.WEBHOOK_RETRY_MAX_DELAY_MS = '20';
process.env.WEBHOOK_RETRY_DEGRADED_INTERVAL_MS = '50';
const originalFetch: any = globalThis.fetch;
let setCalls = 0;
const fetchSpy = mock(async (input: any, init?: any) => {
const url = typeof input === 'string' ? input : input.url;
// Self-test of our own webhook endpoint
if (url === process.env.WEBHOOK_URL) {
return new Response('ok', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
// Register webhook endpoint (first fails, then succeeds)
if (typeof url === 'string' && url.includes('/webhook/set/')) {
setCalls++;
if (setCalls < 2) {
return new Response('unavailable', { status: 503, statusText: 'Service Unavailable' });
}
const body = JSON.stringify({
id: 'test-id',
url: process.env.WEBHOOK_URL,
enabled: true,
events: ['APPLICATION_STARTUP']
});
return new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } });
}
// Verify webhook endpoint (returns enabled)
if (typeof url === 'string' && url.includes('/webhook/find/')) {
const body = JSON.stringify({
enabled: true,
url: process.env.WEBHOOK_URL,
events: ['APPLICATION_STARTUP']
});
return new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return new Response('not found', { status: 404 });
});
// @ts-ignore
globalThis.fetch = fetchSpy;
try {
// Asegurar que no haya un lazo previo corriendo
try { (WebhookManager as any).stopAutoEnsure(); } catch {}
WebhookManager['startAutoEnsure']();
// Esperar a que el lazo logre activar el webhook (o agote timeout)
const deadline = Date.now() + 2000;
while (WebhookManager['autoEnsureActive'] && Date.now() < deadline) {
await new Promise(res => setTimeout(res, 20));
}
expect(WebhookManager['autoEnsureActive']).toBe(false);
expect(fetchSpy).toHaveBeenCalled();
expect(setCalls).toBeGreaterThan(1);
} finally {
try { WebhookManager['stopAutoEnsure'](); } catch {}
// @ts-ignore
globalThis.fetch = originalFetch;
}
});
}); });

Loading…
Cancel
Save