From 995b59060c7c52b3f11b6ef8e1a8dca349599300 Mon Sep 17 00:00:00 2001 From: Alex Alaniz Date: Thu, 26 Mar 2026 11:45:28 -0400 Subject: [PATCH] fix: handle expected fly exec 412s gracefully --- src/app/api/channels/route.ts | 16 ++++++++- src/app/api/gateway/approve-pairing/route.ts | 3 ++ src/lib/fly.ts | 36 ++++++++++++++++++-- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/app/api/channels/route.ts b/src/app/api/channels/route.ts index a224055..7a8a8c9 100644 --- a/src/app/api/channels/route.ts +++ b/src/app/api/channels/route.ts @@ -4,7 +4,12 @@ import * as Sentry from '@sentry/nextjs'; import { authOptions, getUserEmail } from '@/lib/auth'; import { rateLimit, rateLimitResponse } from '@/lib/rate-limit'; import { getInstanceByUserId } from '@/lib/supabase'; -import { getGatewayChannels, setGatewayChannel, removeGatewayChannel } from '@/lib/fly'; +import { + getGatewayChannels, + isFlyMachineNotRunningError, + removeGatewayChannel, + setGatewayChannel, +} from '@/lib/fly'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -50,6 +55,9 @@ export async function GET() { return NextResponse.json({ channels: safe, available: Object.keys(CHANNEL_SCHEMA) }); } catch (error) { + if (isFlyMachineNotRunningError(error)) { + return NextResponse.json({ channels: {}, available: Object.keys(CHANNEL_SCHEMA) }); + } Sentry.captureException(error); return NextResponse.json({ error: 'Failed to fetch channels' }, { status: 500 }); } @@ -118,6 +126,9 @@ export async function POST(req: Request) { return NextResponse.json({ success: true, channel: channelName }); } catch (error) { + if (isFlyMachineNotRunningError(error)) { + return NextResponse.json({ error: 'Gateway must be running to configure channels' }, { status: 409 }); + } Sentry.captureException(error); return NextResponse.json({ error: 'Failed to configure channel' }, { status: 500 }); } @@ -157,6 +168,9 @@ export async function DELETE(req: Request) { return NextResponse.json({ success: true }); } catch (error) { + if (isFlyMachineNotRunningError(error)) { + return NextResponse.json({ error: 'Gateway must be running to configure channels' }, { status: 409 }); + } Sentry.captureException(error); return NextResponse.json({ error: 'Failed to remove channel' }, { status: 500 }); } diff --git a/src/app/api/gateway/approve-pairing/route.ts b/src/app/api/gateway/approve-pairing/route.ts index e4483dd..deed870 100644 --- a/src/app/api/gateway/approve-pairing/route.ts +++ b/src/app/api/gateway/approve-pairing/route.ts @@ -56,6 +56,9 @@ export async function POST() { if (!execRes.ok) { const errText = await execRes.text().catch(() => ''); + if (execRes.status === 412) { + return NextResponse.json({ error: 'Gateway is not running' }, { status: 409 }); + } Sentry.captureMessage('Fly exec failed for approve-pairing', { level: 'warning', extra: { status: execRes.status, body: errText, app: instance.fly_app_name }, diff --git a/src/lib/fly.ts b/src/lib/fly.ts index e951263..2e8f616 100644 --- a/src/lib/fly.ts +++ b/src/lib/fly.ts @@ -92,6 +92,32 @@ type FlyFetchOptions = RequestInit & { expectedStatuses?: number[]; }; +export class FlyApiError extends Error { + status: number; + method: string; + path: string; + responseBody: string; + expected: boolean; + + constructor(args: { status: number; method: string; path: string; responseBody: string; expected: boolean }) { + super(`Fly API ${args.status} on ${args.method} ${args.path}: ${args.responseBody}`); + this.name = 'FlyApiError'; + this.status = args.status; + this.method = args.method; + this.path = args.path; + this.responseBody = args.responseBody; + this.expected = args.expected; + } +} + +export function isFlyApiError(error: unknown, status?: number): error is FlyApiError { + return error instanceof FlyApiError && (status === undefined || error.status === status); +} + +export function isFlyMachineNotRunningError(error: unknown): error is FlyApiError { + return isFlyApiError(error, 412) && error.path.includes('/exec'); +} + async function flyFetch(path: string, options: FlyFetchOptions = {}): Promise { const { expectedStatuses, ...fetchOptions } = options; const token = getFlyToken(); @@ -109,9 +135,15 @@ async function flyFetch(path: string, options: FlyFetchOptions = {}): Promise if (!res.ok) { const body = await res.text().catch(() => ''); - const err = new Error(`Fly API ${res.status} on ${fetchOptions.method ?? 'GET'} ${path}: ${body}`); + const err = new FlyApiError({ + status: res.status, + method: fetchOptions.method ?? 'GET', + path, + responseBody: body, + expected: expectedStatuses?.includes(res.status) ?? false, + }); // Only report to Sentry if this status code is NOT expected - if (!expectedStatuses?.includes(res.status)) { + if (!err.expected) { Sentry.captureException(err, { extra: { responseBody: body, status: res.status } }); } throw err;