diff --git a/src/lib/__tests__/api-http-status.test.ts b/src/lib/__tests__/api-http-status.test.ts new file mode 100644 index 00000000..c0c8b503 --- /dev/null +++ b/src/lib/__tests__/api-http-status.test.ts @@ -0,0 +1,39 @@ +import { AxiosError, AxiosHeaders } from 'axios'; + +import { httpStatusOf } from '@lib/api'; + +/** + * `httpStatusOf` is what lets the SlackConnectScreen poll tell an expected + * auth failure (401 expired token / 403 missing scope) apart from a real + * exception, without pulling axios into the screen. These lock that in. + */ +describe('httpStatusOf', () => { + const axiosErrorWithStatus = (status: number): AxiosError => { + const err = new AxiosError('request failed'); + err.response = { + status, + statusText: '', + headers: {}, + // config/data are unused by httpStatusOf; keep the shape minimal. + config: { headers: new AxiosHeaders() }, + data: {}, + }; + return err; + }; + + it('returns the status code of an axios error with a response', () => { + expect(httpStatusOf(axiosErrorWithStatus(401))).toBe(401); + expect(httpStatusOf(axiosErrorWithStatus(403))).toBe(403); + expect(httpStatusOf(axiosErrorWithStatus(500))).toBe(500); + }); + + it('returns undefined for an axios error with no response (network failure)', () => { + expect(httpStatusOf(new AxiosError('network error'))).toBeUndefined(); + }); + + it('returns undefined for non-axios errors', () => { + expect(httpStatusOf(new Error('boom'))).toBeUndefined(); + expect(httpStatusOf('boom')).toBeUndefined(); + expect(httpStatusOf(undefined)).toBeUndefined(); + }); +}); diff --git a/src/lib/api.ts b/src/lib/api.ts index ba478a82..35c55437 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -261,6 +261,17 @@ export async function fetchSlackConnected( return parsed.data.results.some((i) => i.kind === 'slack'); } +/** + * Extract the HTTP status code from an unknown error, or `undefined` if it + * isn't an axios error with a response. Lets callers of the throwing fetchers + * (e.g. the SlackConnectScreen poll) distinguish expected auth failures — a + * 401 (expired/invalid token) or 403 (missing scope) — from real exceptions + * without importing axios themselves. + */ +export function httpStatusOf(error: unknown): number | undefined { + return axios.isAxiosError(error) ? error.response?.status : undefined; +} + export function handleApiError(error: unknown, operation: string): ApiError { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<{ detail?: string }>; diff --git a/src/ui/tui/screens/SlackConnectScreen.tsx b/src/ui/tui/screens/SlackConnectScreen.tsx index 9db8f74d..52f3da1e 100644 --- a/src/ui/tui/screens/SlackConnectScreen.tsx +++ b/src/ui/tui/screens/SlackConnectScreen.tsx @@ -31,7 +31,7 @@ import { Colors, Icons } from '@ui/tui/styles'; import { PickerMenu, LoadingBox } from '@ui/tui/primitives/index'; import { useKeyBindings, KeyMatch } from '@ui/tui/hooks/useKeyBindings'; import { getSlackAppCard } from '@lib/mcp-role-prompts'; -import { fetchSlackConnected } from '@lib/api'; +import { fetchSlackConnected, httpStatusOf } from '@lib/api'; import { Program } from '@lib/programs/program-registry'; import { getOrAskForProjectData } from '@utils/setup-utils'; import { analytics } from '@utils/analytics'; @@ -162,6 +162,23 @@ export const SlackConnectScreen = ({ store }: SlackConnectScreenProps) => { if (store.session.slackConnected === null) { store.setSlackConnected(false); } + // A 401 (expired/invalid token) or 403 (missing `integration:read` + // scope) is expected here, not a real fault — the graceful + // degradation above is the whole handling. Downgrade those to a + // breadcrumb-style analytics event + debug log so stale-token + // polls stop showing up as exceptions in error tracking. Anything + // else is a genuine failure worth capturing. + const status = httpStatusOf(err); + if (status === 401 || status === 403) { + logToFile( + `[slack-connect] poll skipped: expected ${status} from integrations check`, + ); + analytics.wizardCapture('slack connect check skipped', { + role, + status, + }); + return; + } analytics.captureException( err instanceof Error ? err : new Error(String(err)), { step: 'slack_connected_check' },