diff --git a/static/app/components/pipeline/index.stories.tsx b/static/app/components/pipeline/index.stories.tsx index fe4ce271b316b0..01ac6b1553f0df 100644 --- a/static/app/components/pipeline/index.stories.tsx +++ b/static/app/components/pipeline/index.stories.tsx @@ -112,7 +112,7 @@ function PipelineRunner({ - {pipeline.error && Error: {pipeline.error.message}} + {pipeline.error && Error: {pipeline.error}} {pipeline.view} diff --git a/static/app/components/pipeline/modal.tsx b/static/app/components/pipeline/modal.tsx index 84140a6f65a316..1237c9ce2f6a79 100644 --- a/static/app/components/pipeline/modal.tsx +++ b/static/app/components/pipeline/modal.tsx @@ -114,7 +114,7 @@ function PipelineModal< {t('Start over')} } > - {pipeline.error.message} + {pipeline.error} )} {pipeline.view} diff --git a/static/app/components/pipeline/pipelineIntegrationGitLab.tsx b/static/app/components/pipeline/pipelineIntegrationGitLab.tsx index 543d60a141f594..075075c4b3d4b5 100644 --- a/static/app/components/pipeline/pipelineIntegrationGitLab.tsx +++ b/static/app/components/pipeline/pipelineIntegrationGitLab.tsx @@ -1,8 +1,8 @@ -import {useCallback} from 'react'; +import {useCallback, useEffect} from 'react'; import {z} from 'zod'; import {CodeBlock} from '@sentry/scraps/code'; -import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; +import {defaultFormOptions, setFieldErrors, useScrapsForm} from '@sentry/scraps/form'; import {Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; @@ -50,6 +50,7 @@ interface InstallationConfigAdvanceData { function InstallationConfigStep({ stepData, advance, + advanceError, isAdvancing, }: PipelineStepProps) { const defaults = stepData.defaults ?? {}; @@ -79,6 +80,12 @@ function InstallationConfigStep({ }, }); + useEffect(() => { + if (advanceError) { + setFieldErrors(form, advanceError); + } + }, [advanceError, form]); + const configForm = ( diff --git a/static/app/components/pipeline/types.tsx b/static/app/components/pipeline/types.tsx index 46cdb6f327d570..a12cc0e050e071 100644 --- a/static/app/components/pipeline/types.tsx +++ b/static/app/components/pipeline/types.tsx @@ -1,3 +1,5 @@ +import {RequestError} from 'sentry/utils/requestError/requestError'; + const PIPELINE_NAME_MAP = { integration: 'integration_pipeline', identity: 'identity_provider', @@ -81,7 +83,7 @@ export interface PipelineStepProps< A = Record, > { advance: (data?: A) => void; - advanceError: Error | null; + advanceError: RequestError | null; isAdvancing: boolean; stepData: D; stepIndex: number; @@ -127,7 +129,7 @@ export interface PipelineAdvanceResponse { export interface ApiPipeline> { completionData: C | null; definition: PipelineDefinition; - error: Error | null; + error: string | null; isAdvancing: boolean; isComplete: boolean; isInitializing: boolean; diff --git a/static/app/components/pipeline/usePipeline.spec.tsx b/static/app/components/pipeline/usePipeline.spec.tsx index e4aea329006d46..6ff170f7689fe5 100644 --- a/static/app/components/pipeline/usePipeline.spec.tsx +++ b/static/app/components/pipeline/usePipeline.spec.tsx @@ -18,7 +18,7 @@ function TestHarness({onComplete}: {onComplete?: (data: any) => void} = {}) {
{String(pipeline.isInitializing)}
{String(pipeline.isAdvancing)}
{String(pipeline.isComplete)}
-
{pipeline.error?.message ?? 'none'}
+
{pipeline.error ?? 'none'}
{pipeline.completionData ? JSON.stringify(pipeline.completionData) : 'none'}
diff --git a/static/app/components/pipeline/usePipeline.tsx b/static/app/components/pipeline/usePipeline.tsx index d6a5a0e868b710..cb7be1e66bdd04 100644 --- a/static/app/components/pipeline/usePipeline.tsx +++ b/static/app/components/pipeline/usePipeline.tsx @@ -1,6 +1,8 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {t} from 'sentry/locale'; import {fetchMutation, useMutation} from 'sentry/utils/queryClient'; +import {RequestError} from 'sentry/utils/requestError/requestError'; import {useOrganization} from 'sentry/utils/useOrganization'; import {getPipelineDefinition} from './registry'; @@ -139,7 +141,7 @@ export function usePipeline< reset: resetAdvance, } = useMutation< PipelineAdvanceResponse, - Error, + RequestError, Record, {generation: number} >({ @@ -205,11 +207,24 @@ export function usePipeline< break; } }, - onError: (error: Error, _variables, context) => { + onError: (error: RequestError, _variables, context) => { if (context?.generation !== generationRef.current) { return; } - setState({status: 'error', error}); + // 404 means the pipeline session expired — unrecoverable. + if (error.status === 404) { + setState({ + status: 'error', + error: new Error(t('This flow has expired. Please start over.')), + }); + return; + } + // Other 4xx errors are recoverable (e.g. validation failures) and are + // surfaced via advanceError. Only transition to the error state for + // 5xx or unknown errors which are unrecoverable. + if (!error.status || error.status >= 500) { + setState({status: 'error', error}); + } }, }); @@ -292,10 +307,26 @@ export function usePipeline< ? {stepIndex: state.stepInfo.stepIndex, totalSteps: state.stepInfo.totalSteps} : {stepIndex: 0, totalSteps: definition.steps.length}; - const error = - state.status === 'error' - ? state.error - : (advanceError ?? initializeRest.error ?? null); + // Pipeline-level error displayed by the modal as a full-width alert with a + // "Start over" button. When state.status is 'error', the pipeline has hit an + // unrecoverable failure (backend PipelineStepResult.error() response, expired + // session, or 5xx). Otherwise falls back to initialization errors or + // advanceError — but only when it carries a `detail` message (a non-field + // error). Field-level validation errors from advanceError are handled by step + // components via setFieldErrors() and don't surface here. + const advanceDetailError = + advanceError && typeof (advanceError.responseJSON as any)?.detail === 'string' + ? ((advanceError.responseJSON as any).detail as string) + : null; + + const rawError = + state.status === 'error' ? state.error : (initializeRest.error ?? null); + + const error = rawError + ? ((rawError instanceof RequestError + ? ((rawError.responseJSON as any)?.detail as string | undefined) + : undefined) ?? rawError.message) + : advanceDetailError; const completionData = state.status === 'complete' ? state.data : null;