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
42 changes: 42 additions & 0 deletions src/lib/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { AxiosError, AxiosHeaders } from 'axios';
import { isAuthOrScopeError } from '@lib/api';

/**
* `isAuthOrScopeError` gates whether a caller reports a failure to error
* tracking. The Slack connect poll 403s when the token lacks
* `integration:read` — an expected, gracefully-handled outcome that must
* NOT be captured as a bug. Only genuinely unexpected failures should be.
*/
const axiosErrorWithStatus = (status: number): AxiosError => {
const error = new AxiosError('request failed');
error.response = {
status,
statusText: '',
data: {},
headers: {},
config: { headers: new AxiosHeaders() },
};
return error;
};

describe('isAuthOrScopeError', () => {
it('treats 401 (unauthenticated) as an auth/scope error', () => {
expect(isAuthOrScopeError(axiosErrorWithStatus(401))).toBe(true);
});

it('treats 403 (missing scope) as an auth/scope error', () => {
expect(isAuthOrScopeError(axiosErrorWithStatus(403))).toBe(true);
});

it('does not treat other HTTP failures as auth/scope errors', () => {
expect(isAuthOrScopeError(axiosErrorWithStatus(404))).toBe(false);
expect(isAuthOrScopeError(axiosErrorWithStatus(500))).toBe(false);
});

it('does not treat non-axios errors as auth/scope errors', () => {
expect(isAuthOrScopeError(new Error('boom'))).toBe(false);
expect(isAuthOrScopeError('boom')).toBe(false);
expect(isAuthOrScopeError(undefined)).toBe(false);
});
});
14 changes: 14 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,20 @@ export async function fetchSlackConnected(
return parsed.data.results.some((i) => i.kind === 'slack');
}

/**
* True for the auth/scope failures that mean "the token can't read this",
* not "something is broken": 401 (unauthenticated) and 403 (authenticated
* but missing the required scope). Callers that already degrade gracefully
* on these — e.g. the Slack connect poll, which 403s without the
* `integration:read` scope (see CONNECT_SLACK_SCOPE_ADDITIONS) — use this to
* skip error-tracking reports for an expected, handled outcome.
*/
export function isAuthOrScopeError(error: unknown): boolean {
if (!axios.isAxiosError(error)) return false;
const status = error.response?.status;
return status === 401 || status === 403;
}

export function handleApiError(error: unknown, operation: string): ApiError {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<{ detail?: string }>;
Expand Down
24 changes: 15 additions & 9 deletions 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, isAuthOrScopeError } 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 @@ -155,17 +155,23 @@ export const SlackConnectScreen = ({ store }: SlackConnectScreenProps) => {
})
.catch((err: unknown) => {
if (cancelled) return;
// Capture once and stop polling — repeating a failing call
// every tick would spam error tracking. The nudge copy is
// the fallback either way; a failed check counts as not
// connected so the screen doesn't sit on the loading state.
// Stop polling either way — repeating a failing call every tick
// would spam error tracking, and the nudge copy is the fallback.
// A failed check counts as not connected so the screen doesn't
// sit on the loading state.
if (store.session.slackConnected === null) {
store.setSlackConnected(false);
}
analytics.captureException(
err instanceof Error ? err : new Error(String(err)),
{ step: 'slack_connected_check' },
);
// A 401/403 here is expected and already handled: the token just
// lacks `integration:read`, so we can't tell if Slack is connected
// and fall back to the nudge (see CONNECT_SLACK_SCOPE_ADDITIONS).
// Only report genuinely unexpected failures to error tracking.
if (!isAuthOrScopeError(err)) {
analytics.captureException(
err instanceof Error ? err : new Error(String(err)),
{ step: 'slack_connected_check' },
);
}
});
};
check();
Expand Down
Loading