Skip to content

Commit

Permalink
feat(graphql): improve error handling (cryostatio#1295)
Browse files Browse the repository at this point in the history
Co-authored-by: Andrew Azores <[email protected]>
  • Loading branch information
Josh-Matsuoka and andrewazores authored Aug 7, 2024
1 parent 508d03e commit 9dafc5b
Show file tree
Hide file tree
Showing 6 changed files with 125 additions and 40 deletions.
9 changes: 6 additions & 3 deletions src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import { ErrorView } from '@app/ErrorView/ErrorView';
import { authFailMessage, isAuthFail } from '@app/ErrorView/types';
import { authFailMessage, isAuthFail, missingSSLMessage } from '@app/ErrorView/types';
import { LoadingView } from '@app/Shared/Components/LoadingView';
import {
emptyAutomatedAnalysisFilters,
Expand Down Expand Up @@ -45,7 +45,7 @@ import {
AutomatedAnalysisScore,
AnalysisResult,
} from '@app/Shared/Services/api.types';
import { isGraphQLAuthError } from '@app/Shared/Services/api.utils';
import { isGraphQLAuthError, isGraphQLError, isGraphQLSSLError } from '@app/Shared/Services/api.utils';
import { FeatureLevel } from '@app/Shared/Services/service.types';
import { automatedAnalysisConfigToRecordingAttributes } from '@app/Shared/Services/service.utils';
import { ServiceContext } from '@app/Shared/Services/Services';
Expand Down Expand Up @@ -359,10 +359,13 @@ export const AutomatedAnalysisCard: DashboardCardFC<AutomatedAnalysisCardProps>
.pipe(
first(),
tap((resp) => {
if (resp.data == undefined) {
if (isGraphQLError(resp)) {
if (isGraphQLAuthError(resp)) {
context.target.setAuthFailure();
throw new Error(authFailMessage);
} else if (isGraphQLSSLError(resp)) {
context.target.setSslFailure();
throw new Error(missingSSLMessage);
} else {
throw new Error(resp.errors[0].message);
}
Expand Down
43 changes: 41 additions & 2 deletions src/app/Dashboard/Charts/mbean/MBeanMetricsChartCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
DashboardCardSizes,
DashboardCardDescriptor,
} from '@app/Dashboard/types';
import { ErrorView } from '@app/ErrorView/ErrorView';
import { missingSSLMessage, authFailMessage, isAuthFail } from '@app/ErrorView/types';
import { ThemeType, ThemeSetting } from '@app/Settings/types';
import { MBeanMetrics } from '@app/Shared/Services/api.types';
import { FeatureLevel } from '@app/Shared/Services/service.types';
Expand Down Expand Up @@ -370,6 +372,8 @@ export const MBeanMetricsChartCard: DashboardCardFC<MBeanMetricsChartCardProps>
const addSubscription = useSubscriptions();
const [samples, setSamples] = React.useState([] as Sample[]);
const [isLoading, setLoading] = React.useState(true);
const [errorMessage, setErrorMessage] = React.useState('');
const isError = React.useMemo(() => errorMessage != '', [errorMessage]);

const resizeObserver = React.useRef((): void => undefined);
const [cardWidth, setCardWidth] = React.useState(0);
Expand Down Expand Up @@ -411,9 +415,34 @@ export const MBeanMetricsChartCard: DashboardCardFC<MBeanMetricsChartCardProps>
addSubscription(
serviceContext.target.target().subscribe((_) => {
setSamples([]);
setErrorMessage(''); // Reset on change
}),
);
}, [addSubscription, serviceContext, setSamples, refresh]);
}, [addSubscription, serviceContext, setSamples, setErrorMessage, refresh]);

React.useEffect(() => {
addSubscription(
serviceContext.target.authRetry().subscribe(() => {
setErrorMessage(''); // Reset on retry
}),
);
}, [addSubscription, serviceContext.target, setErrorMessage]);

React.useEffect(() => {
addSubscription(
serviceContext.target.sslFailure().subscribe(() => {
setErrorMessage(missingSSLMessage);
}),
);
}, [addSubscription, serviceContext.target, setErrorMessage]);

React.useEffect(() => {
addSubscription(
serviceContext.target.authFailure().subscribe(() => {
setErrorMessage(authFailMessage);
}),
);
}, [addSubscription, serviceContext.target, setErrorMessage]);

React.useEffect(() => {
refresh();
Expand All @@ -424,6 +453,10 @@ export const MBeanMetricsChartCard: DashboardCardFC<MBeanMetricsChartCardProps>
addSubscription(controllerContext.mbeanController.loading().subscribe(setLoading));
}, [addSubscription, controllerContext, setLoading]);

const authRetry = React.useCallback(() => {
serviceContext.target.setAuthRetry();
}, [serviceContext.target]);

const refreshButton = React.useMemo(
() => (
<Button
Expand Down Expand Up @@ -466,7 +499,13 @@ export const MBeanMetricsChartCard: DashboardCardFC<MBeanMetricsChartCardProps>
[theme, containerRef, props.themeColor, props.isFullHeight, chartKind, cardWidth, samples],
);

return (
return isError ? (
<ErrorView
title={'Error retrieving metrics'}
message={errorMessage}
retry={isAuthFail(errorMessage) ? authRetry : undefined}
/>
) : (
<DashboardCard
id={props.chartKind + '-chart-card'}
dashboardId={props.dashboardId}
Expand Down
78 changes: 50 additions & 28 deletions src/app/Shared/Services/Api.service.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ import {
ActiveRecordingsFilterInput,
RecordingCountResponse,
MBeanMetrics,
MBeanMetricsResponse,
EventType,
NotificationCategory,
HttpError,
Expand All @@ -73,8 +72,18 @@ import {
Metadata,
TargetMetadata,
isTargetMetadata,
MBeanMetricsResponse,
} from './api.types';
import { isHttpError, includesTarget, isHttpOk, isXMLHttpError } from './api.utils';
import {
isHttpError,
includesTarget,
isHttpOk,
isXMLHttpError,
isGraphQLAuthError,
isGraphQLSSLError,
isGraphQLError,
GraphQLError,
} from './api.utils';
import { LoginService } from './Login.service';
import { NotificationService } from './Notifications.service';
import { TargetService } from './Target.service';
Expand Down Expand Up @@ -807,33 +816,32 @@ export class ApiService {
): Observable<T> {
const headers = new Headers();
headers.set('Content-Type', 'application/json');
return this.sendRequest(
'v2.2',
'graphql',
{
method: 'POST',
body: JSON.stringify({
query: query.replace(/[\s]+/g, ' '),
variables,
const req = () =>
this.sendRequest(
'v2.2',
'graphql',
{
method: 'POST',
body: JSON.stringify({
query: query.replace(/[\s]+/g, ' '),
variables,
}),
headers,
},
undefined,
suppressNotifications,
skipStatusCheck,
).pipe(
map((resp) => resp.json()),
concatMap(from),
tap((resp) => {
if (isGraphQLError(resp)) {
this.handleError(new GraphQLError(resp.errors), req);
}
}),
headers,
},
undefined,
suppressNotifications,
skipStatusCheck,
).pipe(
map((resp) => resp.json()),
concatMap(from),
tap((resp) => {
if (suppressNotifications || !resp?.errors?.length) {
return;
}
resp.errors.forEach((err) =>
this.notifications.danger(`Request failed (${err.extensions.classification})`, err.message),
);
}),
first(),
);
first(),
);
return req();
}

downloadRecording(recording: Recording): void {
Expand Down Expand Up @@ -1452,6 +1460,20 @@ export class ApiService {
});
}
throw error;
} else if (isGraphQLError(error)) {
if (isGraphQLAuthError(error)) {
this.target.setAuthFailure();
return this.target.authRetry().pipe(mergeMap(() => retry()));
} else if (isGraphQLSSLError(error)) {
this.target.setSslFailure();
} else {
if (!suppressNotifications) {
error.errors.forEach((err) =>
this.notifications.danger(`Request failed (${err.extensions.classification})`, err.message),
);
}
}
throw error;
}
if (!suppressNotifications) {
this.notifications.danger(`Request failed`, error.message);
Expand Down
29 changes: 22 additions & 7 deletions src/app/Shared/Services/api.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,29 @@ export const isActiveRecording = (toCheck: Recording): toCheck is ActiveRecordin
return (toCheck as ActiveRecording).state !== undefined;
};

/* eslint @typescript-eslint/no-explicit-any: 0 */
export const isGraphQLAuthError = (resp: any): boolean => {
if (resp.errors !== undefined) {
if (resp.errors[0].message.includes('Authentication failed!')) {
return true;
}
// ======================================
// GraphQL Error Handling utils
// ======================================

export class GraphQLError extends Error {
constructor(readonly errors: any[]) {
super();
}
return false;
}

/* eslint @typescript-eslint/no-explicit-any: 0 */
export const isGraphQLError = (resp: any): resp is GraphQLError => {
return Array.isArray(resp?.errors) && resp.errors.length > 0;
};

/* eslint @typescript-eslint/no-explicit-any: 0 */
export const isGraphQLAuthError = (resp: GraphQLError): boolean => {
return isGraphQLError(resp) && resp.errors.some((v) => v.message.includes('Client Error (427)'));
};

/* eslint @typescript-eslint/no-explicit-any: 0 */
export const isGraphQLSSLError = (resp: GraphQLError): boolean => {
return isGraphQLError(resp) && resp.errors.some((v) => v.message.includes('Bad Gateway'));
};

// ======================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ import { from, of } from 'rxjs';
jest.spyOn(defaultServices.settings, 'datetimeFormat').mockReturnValue(of(defaultDatetimeFormat));
jest.spyOn(defaultServices.settings, 'themeSetting').mockReturnValue(of(ThemeSetting.DARK));
jest.spyOn(defaultServices.settings, 'media').mockReturnValue(of(mockMediaQueryList));
jest.spyOn(defaultServices.target, 'authFailure').mockReturnValue(of());
jest.spyOn(defaultServices.target, 'authRetry').mockReturnValue(of());
jest.spyOn(defaultServices.target, 'sslFailure').mockReturnValue(of());

const mockTarget = {
connectUrl: 'service:jmx:rmi://someUrl',
Expand Down
3 changes: 3 additions & 0 deletions src/test/Dashboard/Dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ jest.spyOn(defaultServices.settings, 'themeSetting').mockReturnValue(of(ThemeSet
jest.spyOn(defaultServices.settings, 'media').mockReturnValue(of());
jest.spyOn(defaultServices.settings, 'chartControllerConfig').mockReturnValue(defaultChartControllerConfig);
jest.spyOn(defaultServices.api, 'getTargetMBeanMetrics').mockReturnValue(of({}));
jest.spyOn(defaultServices.target, 'authFailure').mockReturnValue(of());
jest.spyOn(defaultServices.target, 'authRetry').mockReturnValue(of());
jest.spyOn(defaultServices.target, 'sslFailure').mockReturnValue(of());

describe('<Dashboard />', () => {
it('renders correctly', async () => {
Expand Down

0 comments on commit 9dafc5b

Please sign in to comment.