diff --git a/CHANGELOG.md b/CHANGELOG.md index a2d2d5d90..764582a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed GitLab MR inline review comments returning 400 Bad Request on context (unchanged) lines and renamed files. [#1149](https://github.com/sourcebot-dev/sourcebot/pull/1149) - Upgraded `ws` to `^8.20.1`. [#1286](https://github.com/sourcebot-dev/sourcebot/pull/1286) - Upgraded `hono` to `^4.12.24`. [#1289](https://github.com/sourcebot-dev/sourcebot/pull/1289) +- Surfaced an actionable error when the Lighthouse licensing service is unreachable, instead of a generic "unexpected error". [#1293](https://github.com/sourcebot-dev/sourcebot/pull/1293) ## [5.0.1] - 2026-06-04 diff --git a/packages/web/src/features/billing/client.ts b/packages/web/src/features/billing/client.ts index 496956abe..c87415e6c 100644 --- a/packages/web/src/features/billing/client.ts +++ b/packages/web/src/features/billing/client.ts @@ -23,90 +23,67 @@ import { ServicePingResponse, servicePingResponseSchema, } from "./types"; -import { ServiceError } from "@/lib/serviceError"; +import { lighthouseUnreachable, ServiceError } from "@/lib/serviceError"; import { ErrorCode } from "@/lib/errorCodes"; import { StatusCodes } from "http-status-codes"; import { z } from "zod"; -export const client = { - activate: async (body: ActivateRequest): Promise => { - const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/activate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - return parseResponseBody(response, activateResponseSchema); - }, +const requestLighthouse = async ( + path: string, + init: RequestInit, + schema: T, + retryOptions: { retries?: number; backoffMs?: number } = {}, +): Promise | ServiceError> => { + const url = `${env.SOURCEBOT_LIGHTHOUSE_URL}${path}`; - claimActivationCode: async (body: ClaimActivationCodeRequest): Promise => { - const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/claim-activation-code`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + let response: Response; + try { + response = await fetchWithRetry(url, init, retryOptions) + } catch (error) { + return lighthouseUnreachable(url, error); + } - return parseResponseBody(response, claimActivationCodeResponseSchema); - }, + return parseResponseBody(response, schema); +} - ping: async (body: ServicePingRequest): Promise => { - const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/ping`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); +const jsonPost = (body: unknown): RequestInit => ({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), +}); - return parseResponseBody(response, servicePingResponseSchema); +export const client = { + activate: (body: ActivateRequest): Promise => { + return requestLighthouse('/activate', jsonPost(body), activateResponseSchema); }, - pingSchema: async (): Promise | ServiceError> => { - const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/schema`, { - method: 'GET', - }); - - return parseResponseBody(response, z.record(z.string(), z.unknown())); + claimActivationCode: (body: ClaimActivationCodeRequest): Promise => { + return requestLighthouse('/claim-activation-code', jsonPost(body), claimActivationCodeResponseSchema); }, - checkout: async (body: CheckoutRequest): Promise => { - const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/checkout`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - return parseResponseBody(response, checkoutResponseSchema); + ping: (body: ServicePingRequest): Promise => { + return requestLighthouse('/ping', jsonPost(body), servicePingResponseSchema); }, - portal: async (body: PortalRequest): Promise => { - const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/portal`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + pingSchema: (): Promise | ServiceError> => { + return requestLighthouse('/schema', { method: 'GET' }, z.record(z.string(), z.unknown())); + }, - return parseResponseBody(response, portalResponseSchema); + checkout: (body: CheckoutRequest): Promise => { + return requestLighthouse('/checkout', jsonPost(body), checkoutResponseSchema); }, - invoices: async (body: InvoicesRequest): Promise => { - const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/invoices`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + portal: (body: PortalRequest): Promise => { + return requestLighthouse('/portal', jsonPost(body), portalResponseSchema); + }, - return parseResponseBody(response, invoicesResponseSchema); + invoices: (body: InvoicesRequest): Promise => { + return requestLighthouse('/invoices', jsonPost(body), invoicesResponseSchema); }, - offers: async (query: OffersQuery): Promise => { + offers: (query: OffersQuery): Promise => { const params = new URLSearchParams(query); - // @note we don't use a fetchWithRetry here since this api is - // comonly called on the client that has it's own retry mechanisms. - // @see: useOffers.ts - const response = await fetch(`${env.SOURCEBOT_LIGHTHOUSE_URL}/offers?${params}`, { - method: 'GET', - }); - - return parseResponseBody(response, offersResponseSchema); + return requestLighthouse(`/offers?${params}`, { method: 'GET' }, offersResponseSchema, { retries: 0}); }, } diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index fdb09d67d..94d10385e 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -37,4 +37,5 @@ export enum ErrorCode { API_KEY_USAGE_DISABLED = 'API_KEY_USAGE_DISABLED', MCP_SERVER_ALREADY_EXISTS = 'MCP_SERVER_ALREADY_EXISTS', MCP_SERVER_NOT_FOUND = 'MCP_SERVER_NOT_FOUND', + LIGHTHOUSE_UNREACHABLE = 'LIGHTHOUSE_UNREACHABLE', } diff --git a/packages/web/src/lib/serviceError.ts b/packages/web/src/lib/serviceError.ts index a96969824..98656bc68 100644 --- a/packages/web/src/lib/serviceError.ts +++ b/packages/web/src/lib/serviceError.ts @@ -69,6 +69,16 @@ export const unexpectedError = (message: string): ServiceError => { }; } +export const lighthouseUnreachable = (url: string, error: unknown): ServiceError => { + const detail = error instanceof Error ? error.message : String(error); + return { + statusCode: StatusCodes.SERVICE_UNAVAILABLE, + errorCode: ErrorCode.LIGHTHOUSE_UNREACHABLE, + message: `Could not reach the Sourcebot licensing service at ${url}. ` + + `Verify this host has outbound network access to it, then try again. Details: ${detail}`, + }; +} + export const notAuthenticated = (): ServiceError => { return { statusCode: StatusCodes.UNAUTHORIZED,