Skip to content

Commit 5fecd34

Browse files
authored
fix: improve channel connect error diagnostics (#1018)
* fix: improve channel connect error diagnostics * fix: scope runtime health timeout to diagnostics
1 parent cd6e293 commit 5fecd34

11 files changed

Lines changed: 1096 additions & 90 deletions

File tree

apps/controller/openapi.json

Lines changed: 368 additions & 4 deletions
Large diffs are not rendered by default.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type {
2+
ChannelConnectErrorCode,
3+
ChannelConnectPhase,
4+
} from "@nexu/shared";
5+
6+
type ChannelConnectErrorStatus = 422 | 502 | 503 | 504;
7+
8+
type ChannelConnectErrorOptions = {
9+
message: string;
10+
code: ChannelConnectErrorCode;
11+
status: ChannelConnectErrorStatus;
12+
retryable: boolean;
13+
phase: ChannelConnectPhase;
14+
upstreamHost?: string | null;
15+
upstreamStatus?: number | null;
16+
};
17+
18+
export class ChannelConnectError extends Error {
19+
readonly code: ChannelConnectErrorCode;
20+
readonly status: ChannelConnectErrorStatus;
21+
readonly retryable: boolean;
22+
readonly phase: ChannelConnectPhase;
23+
readonly upstreamHost: string | null;
24+
readonly upstreamStatus: number | null;
25+
26+
constructor(options: ChannelConnectErrorOptions) {
27+
super(options.message);
28+
this.name = "ChannelConnectError";
29+
this.code = options.code;
30+
this.status = options.status;
31+
this.retryable = options.retryable;
32+
this.phase = options.phase;
33+
this.upstreamHost = options.upstreamHost ?? null;
34+
this.upstreamStatus = options.upstreamStatus ?? null;
35+
}
36+
}
37+
38+
export function isChannelConnectError(
39+
error: unknown,
40+
): error is ChannelConnectError {
41+
return error instanceof ChannelConnectError;
42+
}

apps/controller/src/routes/channel-routes.ts

Lines changed: 275 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
22
import {
33
botQuotaResponseSchema,
4+
channelConnectErrorSchema,
45
channelListResponseSchema,
56
channelResponseSchema,
67
connectDingtalkSchema,
@@ -23,11 +24,223 @@ import {
2324
whatsappQrWaitResponseSchema,
2425
} from "@nexu/shared";
2526
import type { ControllerContainer } from "../app/container.js";
27+
import { isChannelConnectError } from "../lib/channel-connect-error.js";
2628
import { logger } from "../lib/logger.js";
29+
import {
30+
readProxyFetchEnv,
31+
redactProxyUrl,
32+
shouldBypassProxy,
33+
} from "../lib/proxy-fetch.js";
2734
import type { ControllerBindings } from "../types.js";
2835

2936
const channelIdParamSchema = z.object({ channelId: z.string() });
3037
const errorSchema = z.object({ message: z.string() });
38+
type ControllerLocale = "en" | "zh-CN";
39+
40+
function getOpenclawOrigin(container: ControllerContainer): string | null {
41+
try {
42+
return new URL(container.env.openclawBaseUrl).origin;
43+
} catch {
44+
return null;
45+
}
46+
}
47+
48+
async function getControllerLocale(
49+
container: ControllerContainer,
50+
): Promise<ControllerLocale> {
51+
try {
52+
return await container.configStore.getDesktopLocale();
53+
} catch {
54+
return "en";
55+
}
56+
}
57+
58+
function localizeChannelConnectMessage(
59+
error: unknown,
60+
locale: ControllerLocale,
61+
): string {
62+
if (!isChannelConnectError(error)) {
63+
return locale === "zh-CN"
64+
? "连接失败,请稍后重试。"
65+
: "Connection failed. Please try again.";
66+
}
67+
68+
if (locale === "zh-CN") {
69+
switch (error.code) {
70+
case "invalid_credentials":
71+
return "凭证无效,请检查后重试。";
72+
case "app_id_mismatch":
73+
return "Application ID 与 Bot Token 不匹配,请检查后重试。";
74+
case "timeout":
75+
return "请求超时,请检查网络或代理设置后重试。";
76+
case "network_error":
77+
case "proxy_error":
78+
return "网络请求失败,请检查网络或代理设置后重试。";
79+
case "sync_failed":
80+
return error.phase === "persist_config"
81+
? "凭证已校验,但本地保存配置失败,请稍后重试。"
82+
: "凭证已校验,但本地运行时同步失败,请稍后重试。";
83+
case "upstream_http_error":
84+
return "上游服务返回异常,请稍后重试。";
85+
case "already_connected":
86+
return "渠道已连接,正在刷新...";
87+
}
88+
}
89+
90+
switch (error.code) {
91+
case "invalid_credentials":
92+
return "Credentials are invalid. Check them and try again.";
93+
case "app_id_mismatch":
94+
return "Application ID does not match the provided Bot Token.";
95+
case "timeout":
96+
return "The request timed out. Check your network or proxy settings and try again.";
97+
case "network_error":
98+
case "proxy_error":
99+
return "The network request failed. Check your network or proxy settings and try again.";
100+
case "sync_failed":
101+
return error.phase === "persist_config"
102+
? "Credentials were verified, but saving the local channel config failed. Please try again."
103+
: "Credentials were verified, but syncing the local runtime failed. Please try again.";
104+
case "upstream_http_error":
105+
return "The upstream service returned an error. Please try again later.";
106+
case "already_connected":
107+
return "Channel already connected, refreshing...";
108+
}
109+
}
110+
111+
function getChannelConnectErrorResponse(
112+
requestId: string,
113+
locale: ControllerLocale,
114+
error: unknown,
115+
) {
116+
if (isChannelConnectError(error)) {
117+
return {
118+
status: error.status,
119+
body: {
120+
message: localizeChannelConnectMessage(error, locale),
121+
code: error.code,
122+
requestId,
123+
retryable: error.retryable,
124+
phase: error.phase,
125+
},
126+
upstreamHost: error.upstreamHost,
127+
upstreamStatus: error.upstreamStatus,
128+
} as const;
129+
}
130+
131+
return {
132+
status: 502,
133+
body: {
134+
message: localizeChannelConnectMessage(error, locale),
135+
code: "network_error",
136+
requestId,
137+
retryable: true,
138+
phase: "verify_credentials",
139+
},
140+
upstreamHost: null,
141+
upstreamStatus: null,
142+
} as const;
143+
}
144+
145+
function logChannelConnectFailure(
146+
container: ControllerContainer,
147+
input: {
148+
requestId: string;
149+
channel: "discord" | "telegram";
150+
locale: ControllerLocale;
151+
error: unknown;
152+
},
153+
): {
154+
status: 422 | 502 | 503 | 504;
155+
body: z.infer<typeof channelConnectErrorSchema>;
156+
} {
157+
const response = getChannelConnectErrorResponse(
158+
input.requestId,
159+
input.locale,
160+
input.error,
161+
);
162+
const proxyEnv = readProxyFetchEnv();
163+
const proxyTargetBypassed = response.upstreamHost
164+
? shouldBypassProxy(response.upstreamHost, proxyEnv.noProxy)
165+
: null;
166+
167+
logger.error(
168+
{
169+
requestId: input.requestId,
170+
channel: input.channel,
171+
error:
172+
input.error instanceof Error
173+
? input.error.message
174+
: String(input.error),
175+
errorCode: response.body.code,
176+
errorPhase: response.body.phase,
177+
retryable: response.body.retryable,
178+
httpStatus: response.status,
179+
upstreamHost: response.upstreamHost,
180+
upstreamStatus: response.upstreamStatus,
181+
proxy: {
182+
httpProxyRedacted: redactProxyUrl(proxyEnv.httpProxy),
183+
httpsProxyRedacted: redactProxyUrl(proxyEnv.httpsProxy),
184+
allProxyRedacted: redactProxyUrl(proxyEnv.allProxy),
185+
noProxy: proxyEnv.noProxy,
186+
bypassedForUpstream: proxyTargetBypassed,
187+
},
188+
runtimeState: {
189+
status: container.runtimeState.status,
190+
configSyncStatus: container.runtimeState.configSyncStatus,
191+
skillsSyncStatus: container.runtimeState.skillsSyncStatus,
192+
templatesSyncStatus: container.runtimeState.templatesSyncStatus,
193+
gatewayStatus: container.runtimeState.gatewayStatus,
194+
lastGatewayProbeAt: container.runtimeState.lastGatewayProbeAt,
195+
lastGatewayError: container.runtimeState.lastGatewayError,
196+
},
197+
runtimeEnv: {
198+
manageOpenclawProcess: container.env.manageOpenclawProcess,
199+
gatewayProbeEnabled: container.env.gatewayProbeEnabled,
200+
openclawBaseUrl: getOpenclawOrigin(container),
201+
},
202+
},
203+
"channel_connect_failure",
204+
);
205+
206+
void container.runtimeHealth
207+
.probe({ timeoutMs: 1500 })
208+
.then((runtimeHealth) => {
209+
logger.warn(
210+
{
211+
requestId: input.requestId,
212+
channel: input.channel,
213+
errorCode: response.body.code,
214+
errorPhase: response.body.phase,
215+
runtimeHealth,
216+
process: {
217+
nodeVersion: process.version,
218+
platform: process.platform,
219+
arch: process.arch,
220+
},
221+
},
222+
"channel_connect_failure_context",
223+
);
224+
})
225+
.catch((captureError: unknown) => {
226+
logger.warn(
227+
{
228+
requestId: input.requestId,
229+
channel: input.channel,
230+
error:
231+
captureError instanceof Error
232+
? captureError.message
233+
: String(captureError),
234+
},
235+
"channel_connect_failure_context_failed",
236+
);
237+
});
238+
239+
return {
240+
status: response.status,
241+
body: response.body,
242+
};
243+
}
31244

32245
export function registerChannelRoutes(
33246
app: OpenAPIHono<ControllerBindings>,
@@ -159,10 +372,30 @@ export function registerChannelRoutes(
159372
content: { "application/json": { schema: channelResponseSchema } },
160373
description: "Connected discord channel",
161374
},
162-
409: {
163-
content: { "application/json": { schema: errorSchema } },
375+
422: {
376+
content: {
377+
"application/json": { schema: channelConnectErrorSchema },
378+
},
164379
description: "Invalid credentials",
165380
},
381+
502: {
382+
content: {
383+
"application/json": { schema: channelConnectErrorSchema },
384+
},
385+
description: "Upstream network or proxy failure",
386+
},
387+
503: {
388+
content: {
389+
"application/json": { schema: channelConnectErrorSchema },
390+
},
391+
description: "Local runtime sync failed",
392+
},
393+
504: {
394+
content: {
395+
"application/json": { schema: channelConnectErrorSchema },
396+
},
397+
description: "Upstream timeout",
398+
},
166399
},
167400
}),
168401
async (c) => {
@@ -172,17 +405,15 @@ export function registerChannelRoutes(
172405
200,
173406
);
174407
} catch (error) {
175-
logger.error(
176-
{ error: error instanceof Error ? error.message : String(error) },
177-
"channel_connect_error_discord",
178-
);
179-
return c.json(
180-
{
181-
message:
182-
error instanceof Error ? error.message : "Discord connect failed",
183-
},
184-
409,
185-
);
408+
const requestId = c.get("requestId");
409+
const locale = await getControllerLocale(container);
410+
const response = logChannelConnectFailure(container, {
411+
requestId,
412+
channel: "discord",
413+
locale,
414+
error,
415+
});
416+
return c.json(response.body, response.status);
186417
}
187418
},
188419
);
@@ -246,10 +477,30 @@ export function registerChannelRoutes(
246477
content: { "application/json": { schema: channelResponseSchema } },
247478
description: "Connected telegram channel",
248479
},
249-
409: {
250-
content: { "application/json": { schema: errorSchema } },
480+
422: {
481+
content: {
482+
"application/json": { schema: channelConnectErrorSchema },
483+
},
251484
description: "Invalid credentials",
252485
},
486+
502: {
487+
content: {
488+
"application/json": { schema: channelConnectErrorSchema },
489+
},
490+
description: "Upstream network or proxy failure",
491+
},
492+
503: {
493+
content: {
494+
"application/json": { schema: channelConnectErrorSchema },
495+
},
496+
description: "Local runtime sync failed",
497+
},
498+
504: {
499+
content: {
500+
"application/json": { schema: channelConnectErrorSchema },
501+
},
502+
description: "Upstream timeout",
503+
},
253504
},
254505
}),
255506
async (c) => {
@@ -259,19 +510,15 @@ export function registerChannelRoutes(
259510
200,
260511
);
261512
} catch (error) {
262-
logger.error(
263-
{ error: error instanceof Error ? error.message : String(error) },
264-
"channel_connect_error_telegram",
265-
);
266-
return c.json(
267-
{
268-
message:
269-
error instanceof Error
270-
? error.message
271-
: "Telegram connect failed",
272-
},
273-
409,
274-
);
513+
const requestId = c.get("requestId");
514+
const locale = await getControllerLocale(container);
515+
const response = logChannelConnectFailure(container, {
516+
requestId,
517+
channel: "telegram",
518+
locale,
519+
error,
520+
});
521+
return c.json(response.body, response.status);
275522
}
276523
},
277524
);

0 commit comments

Comments
 (0)