Skip to content

Commit 147aa95

Browse files
fix(web): surface actionable error when Lighthouse is unreachable (#1293)
* fix(web): surface actionable error when Lighthouse is unreachable When a fetch to the Lighthouse licensing service throws at the network layer (DNS, TLS, connection refused, timeout), the raw error bubbled through sew and was flattened into a generic "an unexpected error occurred" message, giving self-hosted operators nothing to act on. Centralize the request in a requestLighthouse helper that catches the throw and returns a LIGHTHOUSE_UNREACHABLE ServiceError naming the URL and underlying cause. As a ServiceError it is preserved by sew instead of genericized, so the real failure reaches the user. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: fill in PR number in CHANGELOG entry Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d02f61c commit 147aa95

4 files changed

Lines changed: 52 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- 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)
1818
- Upgraded `ws` to `^8.20.1`. [#1286](https://github.com/sourcebot-dev/sourcebot/pull/1286)
1919
- Upgraded `hono` to `^4.12.24`. [#1289](https://github.com/sourcebot-dev/sourcebot/pull/1289)
20+
- 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)
2021

2122
## [5.0.1] - 2026-06-04
2223

packages/web/src/features/billing/client.ts

Lines changed: 40 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -23,90 +23,67 @@ import {
2323
ServicePingResponse,
2424
servicePingResponseSchema,
2525
} from "./types";
26-
import { ServiceError } from "@/lib/serviceError";
26+
import { lighthouseUnreachable, ServiceError } from "@/lib/serviceError";
2727
import { ErrorCode } from "@/lib/errorCodes";
2828
import { StatusCodes } from "http-status-codes";
2929
import { z } from "zod";
3030

31-
export const client = {
32-
activate: async (body: ActivateRequest): Promise<ActivateResponse | ServiceError> => {
33-
const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/activate`, {
34-
method: 'POST',
35-
headers: { 'Content-Type': 'application/json' },
36-
body: JSON.stringify(body),
37-
});
38-
39-
return parseResponseBody(response, activateResponseSchema);
40-
},
31+
const requestLighthouse = async <T extends z.ZodTypeAny>(
32+
path: string,
33+
init: RequestInit,
34+
schema: T,
35+
retryOptions: { retries?: number; backoffMs?: number } = {},
36+
): Promise<z.infer<T> | ServiceError> => {
37+
const url = `${env.SOURCEBOT_LIGHTHOUSE_URL}${path}`;
4138

42-
claimActivationCode: async (body: ClaimActivationCodeRequest): Promise<ClaimActivationCodeResponse | ServiceError> => {
43-
const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/claim-activation-code`, {
44-
method: 'POST',
45-
headers: { 'Content-Type': 'application/json' },
46-
body: JSON.stringify(body),
47-
});
39+
let response: Response;
40+
try {
41+
response = await fetchWithRetry(url, init, retryOptions)
42+
} catch (error) {
43+
return lighthouseUnreachable(url, error);
44+
}
4845

49-
return parseResponseBody(response, claimActivationCodeResponseSchema);
50-
},
46+
return parseResponseBody(response, schema);
47+
}
5148

52-
ping: async (body: ServicePingRequest): Promise<ServicePingResponse | ServiceError> => {
53-
const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/ping`, {
54-
method: 'POST',
55-
headers: { 'Content-Type': 'application/json' },
56-
body: JSON.stringify(body),
57-
});
49+
const jsonPost = (body: unknown): RequestInit => ({
50+
method: 'POST',
51+
headers: { 'Content-Type': 'application/json' },
52+
body: JSON.stringify(body),
53+
});
5854

59-
return parseResponseBody(response, servicePingResponseSchema);
55+
export const client = {
56+
activate: (body: ActivateRequest): Promise<ActivateResponse | ServiceError> => {
57+
return requestLighthouse('/activate', jsonPost(body), activateResponseSchema);
6058
},
6159

62-
pingSchema: async (): Promise<Record<string, unknown> | ServiceError> => {
63-
const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/schema`, {
64-
method: 'GET',
65-
});
66-
67-
return parseResponseBody(response, z.record(z.string(), z.unknown()));
60+
claimActivationCode: (body: ClaimActivationCodeRequest): Promise<ClaimActivationCodeResponse | ServiceError> => {
61+
return requestLighthouse('/claim-activation-code', jsonPost(body), claimActivationCodeResponseSchema);
6862
},
6963

70-
checkout: async (body: CheckoutRequest): Promise<CheckoutResponse | ServiceError> => {
71-
const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/checkout`, {
72-
method: 'POST',
73-
headers: { 'Content-Type': 'application/json' },
74-
body: JSON.stringify(body),
75-
});
76-
77-
return parseResponseBody(response, checkoutResponseSchema);
64+
ping: (body: ServicePingRequest): Promise<ServicePingResponse | ServiceError> => {
65+
return requestLighthouse('/ping', jsonPost(body), servicePingResponseSchema);
7866
},
7967

80-
portal: async (body: PortalRequest): Promise<PortalResponse | ServiceError> => {
81-
const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/portal`, {
82-
method: 'POST',
83-
headers: { 'Content-Type': 'application/json' },
84-
body: JSON.stringify(body),
85-
});
68+
pingSchema: (): Promise<Record<string, unknown> | ServiceError> => {
69+
return requestLighthouse('/schema', { method: 'GET' }, z.record(z.string(), z.unknown()));
70+
},
8671

87-
return parseResponseBody(response, portalResponseSchema);
72+
checkout: (body: CheckoutRequest): Promise<CheckoutResponse | ServiceError> => {
73+
return requestLighthouse('/checkout', jsonPost(body), checkoutResponseSchema);
8874
},
8975

90-
invoices: async (body: InvoicesRequest): Promise<InvoicesResponse | ServiceError> => {
91-
const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/invoices`, {
92-
method: 'POST',
93-
headers: { 'Content-Type': 'application/json' },
94-
body: JSON.stringify(body),
95-
});
76+
portal: (body: PortalRequest): Promise<PortalResponse | ServiceError> => {
77+
return requestLighthouse('/portal', jsonPost(body), portalResponseSchema);
78+
},
9679

97-
return parseResponseBody(response, invoicesResponseSchema);
80+
invoices: (body: InvoicesRequest): Promise<InvoicesResponse | ServiceError> => {
81+
return requestLighthouse('/invoices', jsonPost(body), invoicesResponseSchema);
9882
},
9983

100-
offers: async (query: OffersQuery): Promise<OffersResponse | ServiceError> => {
84+
offers: (query: OffersQuery): Promise<OffersResponse | ServiceError> => {
10185
const params = new URLSearchParams(query);
102-
// @note we don't use a fetchWithRetry here since this api is
103-
// comonly called on the client that has it's own retry mechanisms.
104-
// @see: useOffers.ts
105-
const response = await fetch(`${env.SOURCEBOT_LIGHTHOUSE_URL}/offers?${params}`, {
106-
method: 'GET',
107-
});
108-
109-
return parseResponseBody(response, offersResponseSchema);
86+
return requestLighthouse(`/offers?${params}`, { method: 'GET' }, offersResponseSchema, { retries: 0});
11087
},
11188
}
11289

packages/web/src/lib/errorCodes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@ export enum ErrorCode {
3737
API_KEY_USAGE_DISABLED = 'API_KEY_USAGE_DISABLED',
3838
MCP_SERVER_ALREADY_EXISTS = 'MCP_SERVER_ALREADY_EXISTS',
3939
MCP_SERVER_NOT_FOUND = 'MCP_SERVER_NOT_FOUND',
40+
LIGHTHOUSE_UNREACHABLE = 'LIGHTHOUSE_UNREACHABLE',
4041
}

packages/web/src/lib/serviceError.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ export const unexpectedError = (message: string): ServiceError => {
6969
};
7070
}
7171

72+
export const lighthouseUnreachable = (url: string, error: unknown): ServiceError => {
73+
const detail = error instanceof Error ? error.message : String(error);
74+
return {
75+
statusCode: StatusCodes.SERVICE_UNAVAILABLE,
76+
errorCode: ErrorCode.LIGHTHOUSE_UNREACHABLE,
77+
message: `Could not reach the Sourcebot licensing service at ${url}. `
78+
+ `Verify this host has outbound network access to it, then try again. Details: ${detail}`,
79+
};
80+
}
81+
7282
export const notAuthenticated = (): ServiceError => {
7383
return {
7484
statusCode: StatusCodes.UNAUTHORIZED,

0 commit comments

Comments
 (0)