From 3b245e1f6aeed8858f4008c442a25053a4b5d338 Mon Sep 17 00:00:00 2001 From: j8seangel Date: Wed, 14 Aug 2024 16:48:48 +0200 Subject: [PATCH 1/2] api-client cleanup --- .../features/workspace/workspace.slice.ts | 2 +- apps/fishing-map/utils/async-slice.ts | 9 +- libs/api-client/src/api-client.ts | 89 +++---------------- libs/api-client/src/config.ts | 15 ++++ libs/api-client/src/index.ts | 1 + libs/api-client/src/utils/errors.ts | 38 ++++++-- libs/api-client/src/utils/parse.ts | 44 +++++++++ 7 files changed, 110 insertions(+), 88 deletions(-) create mode 100644 libs/api-client/src/config.ts create mode 100644 libs/api-client/src/utils/parse.ts diff --git a/apps/fishing-map/features/workspace/workspace.slice.ts b/apps/fishing-map/features/workspace/workspace.slice.ts index 333911845c..6ae0365c34 100644 --- a/apps/fishing-map/features/workspace/workspace.slice.ts +++ b/apps/fishing-map/features/workspace/workspace.slice.ts @@ -72,7 +72,7 @@ interface WorkspaceSliceState { const initialState: WorkspaceSliceState = { status: AsyncReducerStatus.Idle, customStatus: AsyncReducerStatus.Idle, - error: {}, + error: {} as AsyncError, data: null, password: '', lastVisited: undefined, diff --git a/apps/fishing-map/utils/async-slice.ts b/apps/fishing-map/utils/async-slice.ts index cadcd5774d..50bd5ed6fc 100644 --- a/apps/fishing-map/utils/async-slice.ts +++ b/apps/fishing-map/utils/async-slice.ts @@ -6,6 +6,7 @@ import { createEntityAdapter, IdSelector, } from '@reduxjs/toolkit' +import { ResponseError } from '@globalfishingwatch/api-client' export enum AsyncReducerStatus { Idle = 'idle', @@ -19,9 +20,7 @@ export enum AsyncReducerStatus { Error = 'error', } -export type AsyncError> = { - status?: number // HHTP error codes - message?: string +export type AsyncError> = ResponseError & { metadata?: Metadata } @@ -38,7 +37,7 @@ export type AsyncReducer = { export const asyncInitialState: AsyncReducer = { status: AsyncReducerStatus.Idle, statusId: null, - error: {}, + error: {} as AsyncError, ids: [], currentRequestIds: [], entities: {}, @@ -55,7 +54,7 @@ const getRequestIdsOnFinish = (currentRequestIds: string[], action: any) => { export const createAsyncSlice = < T, U extends { id: AsyncReducerId }, - Reducers extends SliceCaseReducers = SliceCaseReducers, + Reducers extends SliceCaseReducers = SliceCaseReducers >({ name = '', initialState = {} as T, diff --git a/libs/api-client/src/api-client.ts b/libs/api-client/src/api-client.ts index f4b4cd4a5d..6d6c08b7f7 100644 --- a/libs/api-client/src/api-client.ts +++ b/libs/api-client/src/api-client.ts @@ -7,34 +7,18 @@ import { UserPermission, } from '@globalfishingwatch/api-types' import { isUrlAbsolute } from './utils/url' -import { isAuthError, parseAPIError } from './utils/errors' - -export const API_GATEWAY = - process.env.API_GATEWAY || - process.env.REACT_APP_API_GATEWAY || - process.env.NEXT_PUBLIC_API_GATEWAY || - 'https://gateway.api.dev.globalfishingwatch.org' - -export const USER_TOKEN_STORAGE_KEY = 'GFW_API_USER_TOKEN' -export const USER_REFRESH_TOKEN_STORAGE_KEY = 'GFW_API_USER_REFRESH_TOKEN' -export const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v3' - -const DEBUG_API_REQUESTS: boolean = process.env.NEXT_PUBLIC_DEBUG_API_REQUESTS === 'true' -const AUTH_PATH = 'auth' -const REGISTER_PATH = 'registration' -export const GUEST_USER_TYPE = 'guest' - -export type V2MetadataError = Record -export interface V2MessageError { - detail: string - title: string - metadata?: V2MetadataError -} -export interface ResponseError { - status: number - message: string - messages?: V2MessageError[] -} +import { getIsUnauthorizedError, isAuthError, parseAPIError } from './utils/errors' +import { + API_GATEWAY, + API_VERSION, + AUTH_PATH, + DEBUG_API_REQUESTS, + GUEST_USER_TYPE, + REGISTER_PATH, + USER_REFRESH_TOKEN_STORAGE_KEY, + USER_TOKEN_STORAGE_KEY, +} from './config' +import { parseJSON, processStatus } from './utils/parse' interface UserTokens { token: string @@ -62,51 +46,6 @@ interface LibConfig { baseUrl?: string } -const processStatus = ( - response: Response, - requestStatus?: ResourceResponseType -): Promise => { - return new Promise(async (resolve, reject) => { - const { status, statusText } = response - try { - if (response.status >= 200 && response.status < 400) { - return resolve(response) - } - - if (requestStatus === 'default') { - return reject(response) - } - // Compatibility with v1 and v2 errors format - const errors = { - message: '', - messages: [], - } - if (response.status >= 400 && response.status < 500) { - await response.text().then((text) => { - try { - const res = JSON.parse(text) - errors.message = res.message - errors.messages = res.messages - } catch (e: any) { - errors.message = statusText - } - }) - } - return reject({ - status, - message: errors?.message || statusText, - messages: errors.messages, - }) - } catch (e: any) { - return reject({ status, message: statusText }) - } - }) -} - -const parseJSON = (response: Response) => response.json() -const isUnauthorizedError = (error: ResponseError) => - error && error.status > 400 && error.status < 403 - const isClientSide = typeof window !== 'undefined' export type RequestStatus = 'idle' | 'refreshingToken' | 'logging' | 'downloading' @@ -514,7 +453,7 @@ export class GFW_API_CLASS { } } catch (e: any) { if (!this.getToken() && !this.getRefreshToken()) { - const msg = isUnauthorizedError(e) + const msg = getIsUnauthorizedError(e) ? 'Invalid access token' : 'Error trying to generate tokens' if (this.debug) { @@ -567,7 +506,7 @@ export class GFW_API_CLASS { this.status = 'idle' return user } catch (e: any) { - const msg = isUnauthorizedError(e) + const msg = getIsUnauthorizedError(e) ? 'Invalid refresh token' : 'Error trying to refreshing the token' console.warn(e) diff --git a/libs/api-client/src/config.ts b/libs/api-client/src/config.ts new file mode 100644 index 0000000000..468a9703bc --- /dev/null +++ b/libs/api-client/src/config.ts @@ -0,0 +1,15 @@ +export const API_GATEWAY = + process.env.API_GATEWAY || + process.env.REACT_APP_API_GATEWAY || + process.env.NEXT_PUBLIC_API_GATEWAY || + 'https://gateway.api.dev.globalfishingwatch.org' + +export const USER_TOKEN_STORAGE_KEY = 'GFW_API_USER_TOKEN' +export const USER_REFRESH_TOKEN_STORAGE_KEY = 'GFW_API_USER_REFRESH_TOKEN' +export const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v3' + +export const DEBUG_API_REQUESTS: boolean = process.env.NEXT_PUBLIC_DEBUG_API_REQUESTS === 'true' +export const AUTH_PATH = 'auth' +export const REGISTER_PATH = 'registration' +export const GUEST_USER_TYPE = 'guest' +export const CONCURRENT_ERROR_STATUS = 429 diff --git a/libs/api-client/src/index.ts b/libs/api-client/src/index.ts index 93464659c0..5e835ae57f 100644 --- a/libs/api-client/src/index.ts +++ b/libs/api-client/src/index.ts @@ -2,4 +2,5 @@ export * from './utils/url' export * from './utils/search' export * from './utils/errors' export * from './utils/thinning' +export * from './config' export * from './api-client' diff --git a/libs/api-client/src/utils/errors.ts b/libs/api-client/src/utils/errors.ts index 6321c428f6..0d48b5852e 100644 --- a/libs/api-client/src/utils/errors.ts +++ b/libs/api-client/src/utils/errors.ts @@ -1,24 +1,48 @@ -import { ResponseError, V2MetadataError } from '../api-client' +import { CONCURRENT_ERROR_STATUS } from '../config' + +export type V2MetadataError = Record +export interface V2MessageError { + detail: string + title: string + metadata?: V2MetadataError +} +export interface ResponseError { + status: number + message: string + messages?: V2MessageError[] +} + +export const getIsUnauthorizedError = (error?: ResponseError | { status?: number }) => + error && error.status && error.status > 400 && error.status < 403 + +export const getIsConcurrentError = (error?: ResponseError | { status?: number }) => + error?.status === CONCURRENT_ERROR_STATUS + // The 524 timeout from cloudfare is not handled properly // and rejects with a typeError export const crossBrowserTypeErrorMessages = [ 'Load failed', // Safari 'Failed to fetch', // Chromium ] +export const getIsTimeoutError = (error?: ResponseError | { message?: string }) => { + if (!error?.message) return false + return crossBrowserTypeErrorMessages.some((e) => e.includes(error?.message as string)) +} + export const parseAPIErrorStatus = (error: ResponseError) => { - return error.status || (error as any).code || null + return error?.status || (error as any).code || null } export const parseAPIErrorMessage = (error: ResponseError) => { - if (error.messages?.length) { - return error.messages[0]?.detail + if (error?.messages?.length) { + return error?.messages[0]?.detail } - return error.message || '' + return error?.message || '' } export const parseAPIErrorMetadata = (error: ResponseError) => { - if (error.messages?.length) { - return error.messages[0]?.metadata + if (error?.messages?.length) { + return error?.messages[0]?.metadata } return {} as V2MetadataError } diff --git a/libs/api-client/src/utils/parse.ts b/libs/api-client/src/utils/parse.ts new file mode 100644 index 0000000000..d8ff6d9325 --- /dev/null +++ b/libs/api-client/src/utils/parse.ts @@ -0,0 +1,44 @@ +import { ResourceResponseType } from '@globalfishingwatch/api-types' + +export const processStatus = ( + response: Response, + requestStatus?: ResourceResponseType +): Promise => { + return new Promise(async (resolve, reject) => { + const { status, statusText } = response + try { + if (response.status >= 200 && response.status < 400) { + return resolve(response) + } + + if (requestStatus === 'default') { + return reject(response) + } + // Compatibility with v1 and v2 errors format + const errors = { + message: '', + messages: [], + } + if (response.status >= 400 && response.status < 500) { + await response.text().then((text) => { + try { + const res = JSON.parse(text) + errors.message = res.message + errors.messages = res.messages + } catch (e: any) { + errors.message = statusText + } + }) + } + return reject({ + status, + message: errors?.message || statusText, + messages: errors.messages, + }) + } catch (e: any) { + return reject({ status, message: statusText }) + } + }) +} + +export const parseJSON = (response: Response) => response.json() From fdff266afaf7f3c65d7c48a6bc2ca38ae66f8601 Mon Sep 17 00:00:00 2001 From: j8seangel Date: Wed, 14 Aug 2024 17:39:27 +0200 Subject: [PATCH 2/2] download data error handling --- .../download/DownloadActivityByVessel.tsx | 25 ++++----- .../download/DownloadActivityEnvironment.tsx | 18 ++---- .../download/DownloadActivityError.tsx | 55 +++++++++++++++++++ .../download/DownloadActivityGridded.tsx | 19 ++----- .../download/downloadActivity.slice.ts | 39 ++++++++++--- apps/fishing-map/features/reports/Report.tsx | 12 ++-- .../public/locales/source/translations.json | 1 + 7 files changed, 115 insertions(+), 54 deletions(-) create mode 100644 apps/fishing-map/features/download/DownloadActivityError.tsx diff --git a/apps/fishing-map/features/download/DownloadActivityByVessel.tsx b/apps/fishing-map/features/download/DownloadActivityByVessel.tsx index 8f0d3b7589..289c43b891 100644 --- a/apps/fishing-map/features/download/DownloadActivityByVessel.tsx +++ b/apps/fishing-map/features/download/DownloadActivityByVessel.tsx @@ -18,7 +18,7 @@ import { selectIsDownloadActivityError, DateRange, selectDownloadActivityAreaKey, - selectIsDownloadAreaTooBig, + selectIsDownloadActivityTimeoutError, } from 'features/download/downloadActivity.slice' import { EMPTY_FIELD_PLACEHOLDER } from 'utils/info' import { TimelineDatesRange } from 'features/map/controls/MapInfo' @@ -56,6 +56,7 @@ import { getSupportedGroupByOptions, getSupportedTemporalResolutions, } from './download.utils' +import ActivityDownloadError, { useActivityDownloadTimeoutRefresh } from './DownloadActivityError' function DownloadActivityByVessel() { const { t } = useTranslation() @@ -71,7 +72,7 @@ function DownloadActivityByVessel() { const isDownloadLoading = useSelector(selectIsDownloadActivityLoading) const isDownloadError = useSelector(selectIsDownloadActivityError) const isDownloadFinished = useSelector(selectIsDownloadActivityFinished) - const isDownloadAreaTooBig = useSelector(selectIsDownloadAreaTooBig) + const isDownloadTimeoutError = useSelector(selectIsDownloadActivityTimeoutError) const [format, setFormat] = useState(VESSEL_FORMAT_OPTIONS[0].id) const isDownloadReportSupported = getDownloadReportSupported(start, end) const downloadAreaKey = useSelector(selectDownloadActivityAreaKey) @@ -146,7 +147,7 @@ function DownloadActivityByVessel() { bufferValue, bufferOperation, } - await dispatch(downloadActivityThunk(downloadParams)) + const action = await dispatch(downloadActivityThunk(downloadParams)) trackEvent({ category: TrackCategory.DataDownloads, @@ -158,9 +159,14 @@ function DownloadActivityByVessel() { .flat(), ]), }) + return action } + + useActivityDownloadTimeoutRefresh(onDownloadClick) + const parsedLabel = typeof downloadAreaName === 'string' ? parse(downloadAreaName) : downloadAreaName + return (
@@ -226,20 +232,11 @@ function DownloadActivityByVessel() { ))}

) : null} - {isDownloadError && ( -

- {isDownloadAreaTooBig - ? `${t( - 'analysis.errorTooComplex', - 'The geometry of the area is too complex to perform a report, try to simplify and upload again.' - )}` - : `${t('analysis.errorMessage', 'Something went wrong')} 🙈`} -

- )} + {isDownloadError && }