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
19 changes: 3 additions & 16 deletions src/lib/services/workflow-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,12 @@ import type {
WorkflowExecution,
WorkflowIdentifier,
} from '$lib/types/workflows';
import {
decodePayloadAndParseDataToJSON,
type PotentiallyDecodable,
} from '$lib/utilities/decode-payload';
import { decodePayloadAndParseDataToJSON } from '$lib/utilities/decode-payload';
import {
encodePayloads,
setBase64Payload,
} from '$lib/utilities/encode-payload';
import {
handleUnauthorizedOrForbiddenError,
isForbidden,
isUnauthorized,
} from '$lib/utilities/handle-error';
import { isForbidden, isUnauthorized } from '$lib/utilities/handle-error';
import { paginated } from '$lib/utilities/paginated';
import { stringifyWithBigInt } from '$lib/utilities/parse-with-big-int';
import { toListWorkflowQuery } from '$lib/utilities/query/list-workflow-query';
Expand Down Expand Up @@ -176,8 +169,6 @@ export const fetchAllWorkflows = async (

let error = '';
const onError: ErrorCallback = (err) => {
// Kick out to login if 401/403
handleUnauthorizedOrForbiddenError(err);
if (err?.body?.message || err?.status) {
error =
err?.body?.message ??
Expand Down Expand Up @@ -1119,8 +1110,6 @@ export const fetchPaginatedWorkflows = async (
workflowError.set('');

const onError: ErrorCallback = (err) => {
handleUnauthorizedOrForbiddenError(err);

if (get(hideWorkflowQueryErrors)) {
workflowError.set(translate('workflows.workflows-error-querying'));
} else {
Expand Down Expand Up @@ -1155,9 +1144,7 @@ export const fetchPaginatedArchivedWorkflows = async (
request = fetch,
): Promise<PaginatedWorkflowsPromise> => {
return (pageSize = 100, token = '') => {
const onError: ErrorCallback = (err) => {
handleUnauthorizedOrForbiddenError(err);
};
const onError: ErrorCallback = () => {};

const route = routeForApi('workflows.archived', { namespace });
return requestFromAPI<ListWorkflowExecutionsResponse>(route, {
Expand Down
168 changes: 168 additions & 0 deletions src/lib/utilities/api-request-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import type { NetworkError } from '$lib/types/global';

import { has } from './has';
import { isObject, isString } from './is';
import { isNetworkError } from './is-network-error';

export type TemporalAPIError = {
code: number;
message: string;
details: unknown[];
};

export type APIErrorBody = Partial<TemporalAPIError> & {
message?: string;
error?: unknown;
[key: string]: unknown;
};

export type APIErrorResponse = {
status: number;
statusText: string;
statusCode?: number;
body: APIErrorBody;
response?: Response;
message?: string;
};

export type ErrorCallback = (error: APIErrorResponse) => void;

export type RequestErrorHandler = (error: unknown) => void;

export class APIRequestError extends Error implements NetworkError {
statusCode: number;
statusText: string;
response: Response;
body: APIErrorBody;

constructor(response: Response, body: unknown) {
const normalizedBody = toAPIErrorBody(body);
super(errorMessage(response, normalizedBody));
this.name = 'APIRequestError';
this.statusCode = response.status;
this.statusText = response.statusText;
this.response = response;
this.body = normalizedBody;
}
}

export const isAPIRequestError = (error: unknown): error is APIRequestError => {
return error instanceof APIRequestError;
};

export const isAuthenticationError = (error: unknown): boolean => {
return hasStatusCode(error, 401) || hasStatusCode(error, 403);
};

export const parseResponseBody = async (
response: Response,
): Promise<unknown> => {
if (typeof response.text === 'function') {
const text = await response.text();
if (!text) return undefined;

try {
return JSON.parse(text);
} catch {
return { message: text };
}
}

if (typeof response.json === 'function') {
try {
return await response.json();
} catch {
return undefined;
}
}

return undefined;
};

export const toAPIErrorResponse = (
response: Response,
body: unknown,
): APIErrorResponse => {
return {
status: response.status,
statusText: response.statusText,
body: toAPIErrorBody(body),
};
};

export const toAPIRequestError = (
response: Response,
body: unknown,
): APIRequestError => {
return new APIRequestError(response, body);
};

export const handleCaughtRequestError = (
error: unknown,
options: {
notifyOnError: boolean;
handleError: RequestErrorHandler;
},
): never | void => {
if (!options.notifyOnError) {
throw error;
}

if (isAuthenticationError(error)) {
throw error;
}

options.handleError(error);
};

export const normalizeHeaders = (
headers: HeadersInit | undefined,
): Record<string, string> => {
const normalized: Record<string, string> = {};

if (typeof Headers !== 'undefined' && headers instanceof Headers) {
headers.forEach((value, key) => {
normalized[key] = value;
});
} else if (Array.isArray(headers)) {
for (const [key, value] of headers) {
normalized[key] = value;
}
} else if (headers) {
Object.assign(normalized, headers);
}

normalized['Caller-Type'] = 'operator';
return normalized;
};

const toAPIErrorBody = (body: unknown): APIErrorBody => {
if (isObject(body)) return body as APIErrorBody;
if (isString(body)) return { message: body };
return {};
};

const errorMessage = (response: Response, body: APIErrorBody): string => {
if (isString(body.message)) return body.message;
if (isString(body.error)) return body.error;
return response.statusText;
};

const hasStatusCode = (
error: unknown,
statusCode: number | string,
): boolean => {
if (has(error, 'statusCode')) {
return error.statusCode === statusCode;
}

if (has(error, 'status')) {
return error.status === statusCode;
}

if (isNetworkError(error)) {
return error.statusCode === statusCode;
}

return false;
};
47 changes: 39 additions & 8 deletions src/lib/utilities/handle-error.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { handleError } from './handle-error';
import {
handleError,
handleUnauthorizedOrForbiddenError,
} from './handle-error';
import { routeForLoginPage } from './route-for';

const realLocation = window.location.assign;
Expand All @@ -20,50 +23,78 @@ describe('handleError', () => {
vi.clearAllMocks();
});

it('should redirect if it is an unauthorized error with status', () => {
it('should not redirect if it is an unauthorized error with status', () => {
const error = {
status: 401,
statusText: 'Unauthorized',
response: null as unknown as Response,
};

expect(() => handleError(error)).toThrowError();
expect(window.location.assign).toHaveBeenCalledWith(routeForLoginPage());
expect(window.location.assign).not.toHaveBeenCalled();
});

it('should redirect if it is an unauthorized error with statusCode', () => {
it('should not redirect if it is an unauthorized error with statusCode', () => {
const error = {
statusCode: 401,
statusText: 'Unauthorized',
response: null as unknown as Response,
};

expect(() => handleError(error)).toThrowError();
expect(window.location.assign).toHaveBeenCalledWith(routeForLoginPage());
expect(window.location.assign).not.toHaveBeenCalled();
});

it('should redirect if it is a forbidden error with status', () => {
it('should not redirect if it is a forbidden error with status', () => {
const error = {
statusCode: 403,
statusText: 'Forbidden',
response: null as unknown as Response,
};

expect(() => handleError(error)).toThrowError();
expect(window.location.assign).toHaveBeenCalledWith(routeForLoginPage());
expect(window.location.assign).not.toHaveBeenCalled();
});

it('should redirect if it is a forbidden error with statusCode', () => {
it('should not redirect if it is a forbidden error with statusCode', () => {
const error = {
status: 403,
statusText: 'Forbidden',
response: null as unknown as Response,
};

expect(() => handleError(error)).toThrowError();
expect(window.location.assign).not.toHaveBeenCalled();
});

it('should redirect unauthorized errors when explicitly requested', () => {
const error = {
status: 401,
statusText: 'Unauthorized',
body: { message: 'Unauthorized' },
};

handleUnauthorizedOrForbiddenError(error, true, {
redirectToLogin: true,
});

expect(window.location.assign).toHaveBeenCalledWith(routeForLoginPage());
});

it('should not redirect forbidden errors when explicitly requested', () => {
const error = {
status: 403,
statusText: 'Forbidden',
body: { message: 'Forbidden' },
};

handleUnauthorizedOrForbiddenError(error, true, {
redirectToLogin: true,
});

expect(window.location.assign).not.toHaveBeenCalled();
});

it('should not redirect if not 401/403', () => {
const error = {
statusText: 'Forbidden',
Expand Down
25 changes: 9 additions & 16 deletions src/lib/utilities/handle-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { networkError } from '$lib/stores/error';
import { toaster } from '$lib/stores/toaster';
import type { NetworkError } from '$lib/types/global';

import type { APIErrorResponse, TemporalAPIError } from './api-request-manager';
import { has } from './has';
import { isNetworkError } from './is-network-error';
import type { APIErrorResponse, TemporalAPIError } from './request-from-api';
import { routeForLoginPage } from './route-for';

interface NetworkErrorWithReport extends NetworkError {
Expand All @@ -17,7 +17,7 @@ export const handleError = (
error: unknown,
toasts = toaster,
errors = networkError,
isBrowser = BROWSER,
_isBrowser = BROWSER,
): void => {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
Expand All @@ -33,14 +33,6 @@ export const handleError = (
toasts.push({ variant: 'error', message: error.message });
}

if (isUnauthorized(error) && isBrowser) {
window.location.assign(routeForLoginPage());
}

if (isForbidden(error) && isBrowser) {
window.location.assign(routeForLoginPage());
}

if (isNetworkError(error)) {
toasts.push({
variant: 'error',
Expand All @@ -53,18 +45,19 @@ export const handleError = (
throw error;
};

type AuthErrorHandlingOptions = {
redirectToLogin?: boolean;
};

export const handleUnauthorizedOrForbiddenError = (
error: APIErrorResponse,
isBrowser = BROWSER,
options: AuthErrorHandlingOptions = {},
): void => {
if (isUnauthorized(error) && isBrowser) {
window.location.assign(routeForLoginPage());
return;
}
if (!options.redirectToLogin || !isBrowser) return;

if (isForbidden(error) && isBrowser) {
if (isUnauthorized(error)) {
window.location.assign(routeForLoginPage());
return;
}
};

Expand Down
Loading
Loading