From 38b3f072f23525d7bbeeb4624380f7acae622ae5 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:57:28 +0000 Subject: [PATCH] fix(slack): don't capture expected 401/403 scope errors from connected check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Slack-connection poll catches a failed `fetchSlackConnected` call, falls back to the connect nudge, and stops polling. A 401/403 from that endpoint is the documented degradation path — the access token lacks the `integration:read` scope — but the catch still called `analytics.captureException`, so an expected, handled condition surfaced as an error-tracking issue. Add `isMissingScopeError` in the api layer (axios 401/403 predicate) and skip the capture for those statuses while keeping the existing nudge fallback. Genuinely unexpected failures (network, 5xx, parse) are still captured. Generated-By: PostHog Code Task-Id: cb810c10-91bd-4aac-aa37-6628591b3de0 --- src/__tests__/api.test.ts | 36 +++++++++++++++++++++++ src/lib/api.ts | 20 +++++++++++-- src/ui/tui/screens/SlackConnectScreen.tsx | 24 +++++++++------ 3 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/api.test.ts diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts new file mode 100644 index 00000000..a243bfc2 --- /dev/null +++ b/src/__tests__/api.test.ts @@ -0,0 +1,36 @@ +import { AxiosError, AxiosHeaders } from 'axios'; + +import { isMissingScopeError } from '@lib/api'; + +const axiosErrorWithStatus = (status: number): AxiosError => { + const error = new AxiosError('request failed'); + error.response = { + status, + statusText: '', + data: {}, + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + return error; +}; + +describe('isMissingScopeError', () => { + it('treats 401 as the benign missing-scope degradation', () => { + expect(isMissingScopeError(axiosErrorWithStatus(401))).toBe(true); + }); + + it('treats 403 as the benign missing-scope degradation', () => { + expect(isMissingScopeError(axiosErrorWithStatus(403))).toBe(true); + }); + + it('does not swallow other HTTP errors', () => { + expect(isMissingScopeError(axiosErrorWithStatus(500))).toBe(false); + expect(isMissingScopeError(axiosErrorWithStatus(404))).toBe(false); + }); + + it('does not treat non-axios errors as missing-scope', () => { + expect(isMissingScopeError(new Error('boom'))).toBe(false); + expect(isMissingScopeError('boom')).toBe(false); + expect(isMissingScopeError(undefined)).toBe(false); + }); +}); diff --git a/src/lib/api.ts b/src/lib/api.ts index ba478a82..c276f635 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -237,8 +237,9 @@ const IntegrationsResponseSchema = z.object({ /** * Check whether the project already has a Slack integration connected. * Requires the `integration:read` scope. Throws on failure — callers - * (including the SlackConnectScreen poll) decide how to degrade and - * are responsible for capturing the error exactly once. + * (including the SlackConnectScreen poll) decide how to degrade. A + * missing-scope 401/403 is an expected outcome, not a crash: use + * `isMissingScopeError` to tell it apart from failures worth capturing. */ export async function fetchSlackConnected( accessToken: string, @@ -261,6 +262,21 @@ export async function fetchSlackConnected( return parsed.data.results.some((i) => i.kind === 'slack'); } +/** + * True when an error from `fetchSlackConnected` is the expected, documented + * degradation: the access token lacks the `integration:read` scope, so the + * integrations endpoint answers 401/403. This is a benign "scope unavailable + * / treat as not connected" outcome (see `CONNECT_SLACK_SCOPE_ADDITIONS`), + * not a crash — callers fall back to the connect nudge and should NOT capture + * it as an exception. Genuinely unexpected failures (network, 5xx, parse) + * return false here and stay worth capturing. + */ +export function isMissingScopeError(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 }>; diff --git a/src/ui/tui/screens/SlackConnectScreen.tsx b/src/ui/tui/screens/SlackConnectScreen.tsx index 9db8f74d..cac6dbf3 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, isMissingScopeError } from '@lib/api'; import { Program } from '@lib/programs/program-registry'; import { getOrAskForProjectData } from '@utils/setup-utils'; import { analytics } from '@utils/analytics'; @@ -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. 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 is the documented degradation path: the token lacks + // `integration:read`, so the check can't run. That's expected and + // already handled by the nudge fallback — don't pollute error + // tracking with it. Capture only genuinely unexpected failures. + if (!isMissingScopeError(err)) { + analytics.captureException( + err instanceof Error ? err : new Error(String(err)), + { step: 'slack_connected_check' }, + ); + } }); }; check();