@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
JavaScript
/**
* 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 };
};