Files
clawdbot/extensions/msteams/src/errors.ts
2026-01-16 02:59:43 +00:00

159 lines
4.8 KiB
TypeScript

export function formatUnknownError(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
if (err === null) return "null";
if (err === undefined) return "undefined";
if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") {
return String(err);
}
if (typeof err === "symbol") return err.description ?? err.toString();
if (typeof err === "function") {
return err.name ? `[function ${err.name}]` : "[function]";
}
try {
return JSON.stringify(err) ?? "unknown error";
} catch {
return "unknown error";
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function extractStatusCode(err: unknown): number | null {
if (!isRecord(err)) return null;
const direct = err.statusCode ?? err.status;
if (typeof direct === "number" && Number.isFinite(direct)) return direct;
if (typeof direct === "string") {
const parsed = Number.parseInt(direct, 10);
if (Number.isFinite(parsed)) return parsed;
}
const response = err.response;
if (isRecord(response)) {
const status = response.status;
if (typeof status === "number" && Number.isFinite(status)) return status;
if (typeof status === "string") {
const parsed = Number.parseInt(status, 10);
if (Number.isFinite(parsed)) return parsed;
}
}
return null;
}
function extractRetryAfterMs(err: unknown): number | null {
if (!isRecord(err)) return null;
const direct = err.retryAfterMs ?? err.retry_after_ms;
if (typeof direct === "number" && Number.isFinite(direct) && direct >= 0) {
return direct;
}
const retryAfter = err.retryAfter ?? err.retry_after;
if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
return retryAfter >= 0 ? retryAfter * 1000 : null;
}
if (typeof retryAfter === "string") {
const parsed = Number.parseFloat(retryAfter);
if (Number.isFinite(parsed) && parsed >= 0) return parsed * 1000;
}
const response = err.response;
if (!isRecord(response)) return null;
const headers = response.headers;
if (!headers) return null;
if (isRecord(headers)) {
const raw = headers["retry-after"] ?? headers["Retry-After"];
if (typeof raw === "string") {
const parsed = Number.parseFloat(raw);
if (Number.isFinite(parsed) && parsed >= 0) return parsed * 1000;
}
}
// Fetch Headers-like interface
if (
typeof headers === "object" &&
headers !== null &&
"get" in headers &&
typeof (headers as { get?: unknown }).get === "function"
) {
const raw = (headers as { get: (name: string) => string | null }).get("retry-after");
if (raw) {
const parsed = Number.parseFloat(raw);
if (Number.isFinite(parsed) && parsed >= 0) return parsed * 1000;
}
}
return null;
}
export type MSTeamsSendErrorKind = "auth" | "throttled" | "transient" | "permanent" | "unknown";
export type MSTeamsSendErrorClassification = {
kind: MSTeamsSendErrorKind;
statusCode?: number;
retryAfterMs?: number;
};
/**
* Classify outbound send errors for safe retries and actionable logs.
*
* Important: We only mark errors as retryable when we have an explicit HTTP
* status code that indicates the message was not accepted (e.g. 429, 5xx).
* For transport-level errors where delivery is ambiguous, we prefer to avoid
* retries to reduce the chance of duplicate posts.
*/
export function classifyMSTeamsSendError(err: unknown): MSTeamsSendErrorClassification {
const statusCode = extractStatusCode(err);
const retryAfterMs = extractRetryAfterMs(err);
if (statusCode === 401 || statusCode === 403) {
return { kind: "auth", statusCode };
}
if (statusCode === 429) {
return {
kind: "throttled",
statusCode,
retryAfterMs: retryAfterMs ?? undefined,
};
}
if (statusCode === 408 || (statusCode != null && statusCode >= 500)) {
return {
kind: "transient",
statusCode,
retryAfterMs: retryAfterMs ?? undefined,
};
}
if (statusCode != null && statusCode >= 400) {
return { kind: "permanent", statusCode };
}
return {
kind: "unknown",
statusCode: statusCode ?? undefined,
retryAfterMs: retryAfterMs ?? undefined,
};
}
export function formatMSTeamsSendErrorHint(
classification: MSTeamsSendErrorClassification,
): string | undefined {
if (classification.kind === "auth") {
return "check msteams appId/appPassword/tenantId (or env vars MSTEAMS_APP_ID/MSTEAMS_APP_PASSWORD/MSTEAMS_TENANT_ID)";
}
if (classification.kind === "throttled") {
return "Teams throttled the bot; backing off may help";
}
if (classification.kind === "transient") {
return "transient Teams/Bot Framework error; retry may succeed";
}
return undefined;
}