Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/lib/__tests__/api-http-status.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
11 changes: 11 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down
19 changes: 18 additions & 1 deletion src/ui/tui/screens/SlackConnectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' },
Expand Down
Loading