UNPKG

@debugg-ai/debugg-ai-mcp

Version:

Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.

149 lines (148 loc) 5.84 kB
/** * Tunnels Service * Provisions short-lived ngrok keys for MCP-managed tunnel setup. * Called before executeWorkflow so the tunnel URL is known before execution starts. */ import { Telemetry, TelemetryEvents } from '../utils/telemetry.js'; /** * Typed error thrown by provision() when the backend/ngrok path fails. * Carries diagnostic fields a retry wrapper (bead 7nx) can use to decide * whether to retry, and that handler error messages can surface so users * have something actionable to file bug reports against. */ export class TunnelProvisionError extends Error { status; code; requestId; networkCode; retryable; constructor(opts) { super(opts.message); this.name = 'TunnelProvisionError'; this.status = opts.status; this.code = opts.code; this.requestId = opts.requestId; this.networkCode = opts.networkCode; this.retryable = opts.retryable; } /** * Stable one-line suffix for user-facing error messages. * Example: '(status: 503, request-id: abc123, retryable)' or '(network: ECONNRESET, retryable)'. */ diagnosticSuffix() { const parts = []; if (this.status != null) parts.push(`status: ${this.status}`); if (this.code) parts.push(`code: ${this.code}`); if (this.requestId) parts.push(`request-id: ${this.requestId}`); if (this.networkCode) parts.push(`network: ${this.networkCode}`); parts.push(this.retryable ? 'retryable' : 'not-retryable'); return `(${parts.join(', ')})`; } } /** * Classify an axios-interceptor-rewritten error (or any thrown Error) into a * TunnelProvisionError with retryable semantics. Called from provision(). * * Retryable: 5xx, 408 (request timeout), 429 (rate limit), and any network * error (no response received — ECONNRESET / ECONNREFUSED / timeout). * Not retryable: 4xx other than 408/429 — those indicate auth/quota/input * problems that won't self-heal on the same API key. */ export function classifyProvisionError(err) { const e = err; const message = e?.message ? String(e.message) : 'Tunnel provisioning failed'; const status = typeof e?.statusCode === 'number' ? e.statusCode : undefined; const data = e?.responseData; const code = data && typeof data === 'object' && typeof data.code === 'string' ? data.code : undefined; const headers = e?.responseHeaders; const requestId = headers && typeof headers === 'object' ? ((headers['x-request-id'] || headers['X-Request-Id']) ?? undefined) : undefined; const networkCode = typeof e?.networkCode === 'string' ? e.networkCode : undefined; let retryable; if (status == null) { retryable = true; } else if (status >= 500) { retryable = true; } else if (status === 408 || status === 429) { retryable = true; } else { retryable = false; } return new TunnelProvisionError({ message, status, code, requestId, networkCode, retryable }); } const DEFAULT_BACKOFF_MS = [500, 1500, 3000]; const DEFAULT_MAX_ATTEMPTS = 3; export const createTunnelsService = (tx) => { async function provision(purpose = 'workflow') { let response; try { response = await tx.post('api/v1/tunnels/', { purpose }); } catch (err) { throw classifyProvisionError(err); } if (!response?.tunnelId || !response?.tunnelKey) { throw new TunnelProvisionError({ message: 'Tunnel provisioning returned a success response missing tunnelId or tunnelKey', retryable: false, }); } return { tunnelId: response.tunnelId, tunnelKey: response.tunnelKey, keyId: response.keyId, expiresAt: response.expiresAt, }; } async function provisionWithRetry(opts = {}) { const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; const backoff = opts.backoffMs ?? DEFAULT_BACKOFF_MS; const sleep = opts.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms))); let lastErr; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const result = await provision(opts.purpose); if (attempt > 1) { Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, { attempt, outcome: 'success', }); } return result; } catch (err) { const e = err instanceof TunnelProvisionError ? err : classifyProvisionError(err); lastErr = e; const isLastAttempt = attempt >= maxAttempts; const willRetry = e.retryable && !isLastAttempt; Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, { attempt, outcome: willRetry ? 'will-retry' : 'giving-up', status: e.status, code: e.code, requestId: e.requestId, networkCode: e.networkCode, retryable: e.retryable, }); if (!willRetry) throw e; const waitMs = backoff[attempt - 1] ?? backoff[backoff.length - 1] ?? 0; await sleep(waitMs); } } // Unreachable in practice — loop always returns or throws. throw lastErr ?? new TunnelProvisionError({ message: 'provisionWithRetry exhausted attempts without a classified error', retryable: false, }); } return { provision, provisionWithRetry }; };