Commit 908543b8 authored by tom goriunov's avatar tom goriunov Committed by GitHub

support 429 "Too many requests" API error (#1004)

* get client key on 429 error

* set key to cookie and pass it in query params

* move csrf-token header to useApiFetch

* pin host for preview

* actual layout

* support new changes in API

* proxy headers from API

* add header to request

* remove next.js proxy flag

* fix ts

* refactor

* add tests
parent b38b5a8e
...@@ -136,6 +136,7 @@ const config = Object.freeze({ ...@@ -136,6 +136,7 @@ const config = Object.freeze({
host: appHost, host: appHost,
port: appPort, port: appPort,
baseUrl, baseUrl,
useNextJsProxy: getEnvValue(process.env.NEXT_PUBLIC_USE_NEXT_JS_PROXY) === 'true',
}, },
ad: { ad: {
adBannerProvider: getAdBannerProvider(), adBannerProvider: getAdBannerProvider(),
......
...@@ -128,6 +128,7 @@ In order to enable "My Account" feature you have to configure following set of v ...@@ -128,6 +128,7 @@ In order to enable "My Account" feature you have to configure following set of v
| NEXT_PUBLIC_APP_HOST | `string` | App host | yes | - | `blockscout.com` | | NEXT_PUBLIC_APP_HOST | `string` | App host | yes | - | `blockscout.com` |
| NEXT_PUBLIC_APP_PORT | `number` | Port where app is running | - | `3000` | `3001` | | NEXT_PUBLIC_APP_PORT | `number` | Port where app is running | - | `3000` | `3001` |
| NEXT_PUBLIC_APP_ENV | `string` | Current app env (e.g development, review or production). Used for Sentry.io configuration | - | equals to `process.env.NODE_ENV` | `production` | | NEXT_PUBLIC_APP_ENV | `string` | Current app env (e.g development, review or production). Used for Sentry.io configuration | - | equals to `process.env.NODE_ENV` | `production` |
| NEXT_PUBLIC_USE_NEXT_JS_PROXY | `boolean` | Tells the app to proxy all APIs request through the NextJs app. **We strongly advise not to use it in the production environment** | - | `false` | `true` |
## API configuration ## API configuration
......
<svg viewBox="0 0 177 100" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 177 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.961 7.644a2 2 0 0 0-2.251-2.812l-76.313 17.5a2 2 0 0 0-.727 3.569l17.58 12.744v17.636a2.001 2.001 0 0 0 3.321 1.502l10.716-9.426 21.384 9.744a2 2 0 0 0 2.634-.957l23.656-49.5ZM108.308 35.92 93.583 25.247l62.923-14.43-48.198 25.104Zm.942 1.764v14.173l8.367-7.36a.385.385 0 0 1 .022-.018l.016-.014a.998.998 0 0 1 .214-.242l37.608-30.616-46.227 24.077Zm31.293 15.962-19.927-9.08 39.719-32.335-19.792 41.415ZM93.278 65.729a1.5 1.5 0 0 0 1.93 2.296 93.435 93.435 0 0 0 2.449-2.13 57.65 57.65 0 0 0 .819-.753l.044-.042.012-.011.004-.004.001-.001L97.5 64l1.038 1.083a1.5 1.5 0 0 0-2.075-2.167l-.002.002-.008.008-.037.035a19.011 19.011 0 0 1-.154.145 90.663 90.663 0 0 1-2.984 2.623Zm-5.037 7.714a1.5 1.5 0 0 0-1.751-2.436 105.47 105.47 0 0 1-7.163 4.73 1.5 1.5 0 1 0 1.547 2.57c2.69-1.618 5.17-3.284 7.367-4.864Zm-15.172 9.06a1.5 1.5 0 0 0-1.28-2.714c-2.556 1.205-5.207 2.277-7.906 3.13a1.5 1.5 0 0 0 .905 2.86c2.847-.9 5.624-2.024 8.28-3.276Zm-17.046 5.22a1.5 1.5 0 0 0-.358-2.98A34.938 34.938 0 0 1 51.5 85c-1.392 0-2.728-.09-4.012-.26a1.5 1.5 0 1 0-.394 2.973c1.416.188 2.884.287 4.406.287 1.51 0 3.02-.097 4.523-.278Zm-17.422-2.388a1.5 1.5 0 1 0 1.212-2.745c-2.517-1.11-4.783-2.55-6.816-4.194a1.5 1.5 0 1 0-1.887 2.332c2.216 1.792 4.705 3.377 7.491 4.607ZM24.974 74.523a1.5 1.5 0 1 0 2.338-1.881C25.51 70.403 24 68.076 22.756 65.86a1.5 1.5 0 0 0-2.616 1.47c1.311 2.333 2.911 4.803 4.834 7.193Zm-8.515-15a1.5 1.5 0 0 0 2.796-1.086 49.986 49.986 0 0 1-1-2.813 32.043 32.043 0 0 1-.29-.956l-.013-.045-.002-.01a1.5 1.5 0 0 0-2.899.775l1.45-.388-1.45.388.001.003.002.005.004.018.018.061.064.227c.057.196.143.478.258.837.23.718.579 1.741 1.06 2.984Z" fill="currentColor"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M166.961 7.644a2 2 0 0 0-2.251-2.812l-76.313 17.5a2 2 0 0 0-.727 3.569l17.58 12.744v17.636a2.001 2.001 0 0 0 3.321 1.502l10.716-9.426 21.384 9.744a2 2 0 0 0 2.634-.957l23.656-49.5ZM108.308 35.92 93.583 25.247l62.923-14.43-48.198 25.104Zm.942 1.764v14.173l8.367-7.36a.385.385 0 0 1 .022-.018l.016-.014a.998.998 0 0 1 .214-.242l37.608-30.616-46.227 24.077Zm31.293 15.962-19.927-9.08 39.719-32.335-19.792 41.415ZM93.278 65.729a1.5 1.5 0 0 0 1.93 2.296 93.435 93.435 0 0 0 2.449-2.13 57.65 57.65 0 0 0 .819-.753l.044-.042.012-.011.004-.004.001-.001L97.5 64l1.038 1.083a1.5 1.5 0 0 0-2.075-2.167l-.002.002-.008.008-.037.035a19.011 19.011 0 0 1-.154.145 90.663 90.663 0 0 1-2.984 2.623Zm-5.037 7.714a1.5 1.5 0 0 0-1.751-2.436 105.47 105.47 0 0 1-7.163 4.73 1.5 1.5 0 1 0 1.547 2.57c2.69-1.618 5.17-3.284 7.367-4.864Zm-15.172 9.06a1.5 1.5 0 0 0-1.28-2.714c-2.556 1.205-5.207 2.277-7.906 3.13a1.5 1.5 0 0 0 .905 2.86c2.847-.9 5.624-2.024 8.28-3.276Zm-17.046 5.22a1.5 1.5 0 0 0-.358-2.98A34.938 34.938 0 0 1 51.5 85c-1.392 0-2.728-.09-4.012-.26a1.5 1.5 0 1 0-.394 2.973A33.44 33.44 0 0 0 51.5 88c1.51 0 3.02-.097 4.523-.278Zm-17.422-2.388a1.5 1.5 0 1 0 1.212-2.745c-2.517-1.11-4.783-2.55-6.816-4.194a1.5 1.5 0 1 0-1.887 2.332c2.216 1.792 4.705 3.377 7.491 4.607ZM24.974 74.523a1.5 1.5 0 1 0 2.338-1.881C25.51 70.403 24 68.076 22.756 65.86a1.5 1.5 0 0 0-2.616 1.47c1.311 2.333 2.911 4.803 4.834 7.193Zm-8.515-15a1.5 1.5 0 0 0 2.796-1.086 49.986 49.986 0 0 1-1-2.813 32.043 32.043 0 0 1-.29-.956l-.013-.045-.002-.01a1.5 1.5 0 0 0-2.899.775l1.45-.388-1.45.388.001.003.002.005.004.018.018.061.064.227c.057.196.143.478.258.837.23.718.579 1.741 1.06 2.984Z" fill="currentColor"/>
</svg> </svg>
<svg viewBox="0 0 201 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M200.703 76.7c0 3.326-.638 6.448-1.914 9.364-1.23 2.917-2.939 5.47-5.127 7.657-2.187 2.187-4.739 3.919-7.656 5.195-2.917 1.23-6.039 1.846-9.365 1.846h-4.239c-3.326 0-6.448-.616-9.365-1.846-2.917-1.276-5.469-3.008-7.656-5.195-2.188-2.188-3.919-4.74-5.195-7.657-1.231-2.916-1.846-6.038-1.846-9.365a2.952 2.952 0 0 1 2.499-2.917l2.469-.382a3.339 3.339 0 0 1 3.85 3.3c0 2.187.41 4.237 1.231 6.152a16.853 16.853 0 0 0 3.349 4.99 15.981 15.981 0 0 0 4.922 3.35c1.914.82 3.965 1.23 6.152 1.23h3.35c2.188 0 4.216-.41 6.084-1.23 1.914-.82 3.578-1.937 4.99-3.35a16.21 16.21 0 0 0 3.418-4.99c.821-1.915 1.231-3.965 1.231-6.153V52.363c-2.279 2.461-4.968 4.444-8.067 5.947-3.099 1.459-6.425 2.188-9.98 2.188h-1.436c-3.326 0-6.448-.615-9.365-1.846-2.917-1.276-5.469-3.008-7.656-5.195-2.188-2.188-3.919-4.74-5.195-7.656-1.231-2.962-1.846-6.107-1.846-9.434V24.063c0-3.327.615-6.45 1.846-9.366 1.276-2.916 3.007-5.468 5.195-7.656 2.187-2.187 4.739-3.896 7.656-5.127C165.954.638 169.076 0 172.402 0h4.239c3.326 0 6.448.638 9.365 1.914 2.917 1.23 5.469 2.94 7.656 5.127 2.188 2.188 3.897 4.74 5.127 7.656 1.276 2.917 1.914 6.039 1.914 9.366v52.636Zm-8.818-52.638c0-2.187-.41-4.215-1.231-6.084a15.198 15.198 0 0 0-3.418-4.99c-1.412-1.412-3.076-2.529-4.99-3.35-1.868-.82-3.896-1.23-6.084-1.23h-3.35c-2.187 0-4.238.41-6.152 1.23a15.982 15.982 0 0 0-4.922 3.35c-1.412 1.413-2.529 3.076-3.349 4.99-.821 1.869-1.231 3.897-1.231 6.084v12.305c0 2.188.41 4.239 1.231 6.153a16.855 16.855 0 0 0 3.349 4.99 15.983 15.983 0 0 0 4.922 3.35c1.914.82 3.965 1.23 6.152 1.23h3.35c2.188 0 4.216-.41 6.084-1.23 1.914-.82 3.578-1.938 4.99-3.35a16.211 16.211 0 0 0 3.418-4.99c.821-1.915 1.231-3.965 1.231-6.153V24.063ZM47.829 81.818v16.825a8.752 8.752 0 0 1-8.752-8.752v-8.073H3.275a3.275 3.275 0 0 1-2.85-4.89L42.38 2.836a2.914 2.914 0 0 1 5.45 1.436v69.203h8.345a8.345 8.345 0 0 1-8.345 8.344Zm-8.752-56.513L12.28 73.474h26.797V25.305ZM99.766 8.408c-2.188 0-4.239.41-6.153 1.23a15.973 15.973 0 0 0-4.922 3.35c-1.412 1.413-2.529 3.076-3.35 4.99-.82 1.869-1.23 3.897-1.23 6.084a3.372 3.372 0 0 1-3.913 3.328l-2.409-.392a2.974 2.974 0 0 1-2.496-2.936c0-3.326.615-6.448 1.846-9.365 1.276-2.916 3.008-5.468 5.195-7.656 2.188-2.187 4.74-3.896 7.656-5.127C92.907.638 96.03 0 99.356 0h4.169c3.327 0 6.449.592 9.366 1.777 2.962 1.185 5.537 2.894 7.724 5.127 2.188 2.233 3.897 4.922 5.127 8.067 1.276 3.099 1.914 6.608 1.914 10.527 0 3.874-.592 7.77-1.777 11.69a66.921 66.921 0 0 1-4.58 11.552 87.114 87.114 0 0 1-6.494 11.006 147.574 147.574 0 0 1-7.451 10.049c-5.971 7.337-12.738 14.401-20.303 21.191h32.881a8.409 8.409 0 0 1 8.408 8.408H73.174v-2.253c0-2.988 1.345-5.81 3.612-7.756 7.076-6.073 13.482-12.33 19.22-18.77a161.747 161.747 0 0 0 8.408-10.322 100.423 100.423 0 0 0 7.315-11.211c2.141-3.874 3.85-7.793 5.126-11.758 1.322-4.01 1.983-7.952 1.983-11.826 0-2.871-.433-5.378-1.299-7.52-.866-2.141-2.028-3.919-3.486-5.332a13.92 13.92 0 0 0-5.059-3.144 16.026 16.026 0 0 0-5.879-1.094h-3.35Z" fill="currentColor"/>
</svg>
export default function isBodyAllowed(method: string | undefined | null) {
return method && ![ 'GET', 'HEAD' ].includes(method);
}
...@@ -5,5 +5,9 @@ import appConfig from 'configs/app/config'; ...@@ -5,5 +5,9 @@ import appConfig from 'configs/app/config';
// unsuccessfully tried different ways, even custom local dev domain // unsuccessfully tried different ways, even custom local dev domain
// so for local development we have to use next.js api as proxy server // so for local development we have to use next.js api as proxy server
export default function isNeedProxy() { export default function isNeedProxy() {
if (appConfig.app.useNextJsProxy) {
return true;
}
return appConfig.app.host === 'localhost' && appConfig.app.host !== appConfig.api.host; return appConfig.app.host === 'localhost' && appConfig.app.host !== appConfig.api.host;
} }
...@@ -474,6 +474,11 @@ export const RESOURCES = { ...@@ -474,6 +474,11 @@ export const RESOURCES = {
path: '/api/v2/config/backend-version', path: '/api/v2/config/backend-version',
}, },
// OTHER
api_v2_key: {
path: '/api/v2/key',
},
// DEPRECATED // DEPRECATED
old_api: { old_api: {
path: '/api', path: '/api',
......
import { useQueryClient } from '@tanstack/react-query';
import _pickBy from 'lodash/pickBy';
import React from 'react'; import React from 'react';
import type { CsrfData } from 'types/client/account';
import isBodyAllowed from 'lib/api/isBodyAllowed';
import isNeedProxy from 'lib/api/isNeedProxy'; import isNeedProxy from 'lib/api/isNeedProxy';
import { getResourceKey } from 'lib/api/useApiQuery';
import * as cookies from 'lib/cookies'; import * as cookies from 'lib/cookies';
import type { Params as FetchParams } from 'lib/hooks/useFetch'; import type { Params as FetchParams } from 'lib/hooks/useFetch';
import useFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
...@@ -17,28 +23,34 @@ export interface Params<R extends ResourceName> { ...@@ -17,28 +23,34 @@ export interface Params<R extends ResourceName> {
export default function useApiFetch() { export default function useApiFetch() {
const fetch = useFetch(); const fetch = useFetch();
const queryClient = useQueryClient();
const { token: csrfToken } = queryClient.getQueryData<CsrfData>(getResourceKey('csrf')) || {};
return React.useCallback(<R extends ResourceName, SuccessType = unknown, ErrorType = unknown>( return React.useCallback(<R extends ResourceName, SuccessType = unknown, ErrorType = unknown>(
resourceName: R, resourceName: R,
{ pathParams, queryParams, fetchParams }: Params<R> = {}, { pathParams, queryParams, fetchParams }: Params<R> = {},
) => { ) => {
const apiToken = cookies.get(cookies.NAMES.API_TOKEN);
const resource: ApiResource = RESOURCES[resourceName]; const resource: ApiResource = RESOURCES[resourceName];
const url = buildUrl(resourceName, pathParams, queryParams); const url = buildUrl(resourceName, pathParams, queryParams);
const withBody = isBodyAllowed(fetchParams?.method);
const headers = _pickBy({
'x-endpoint': resource.endpoint && isNeedProxy() ? resource.endpoint : undefined,
Authorization: resource.endpoint && resource.needAuth ? apiToken : undefined,
'x-csrf-token': withBody && csrfToken ? csrfToken : undefined,
}, Boolean) as HeadersInit;
return fetch<SuccessType, ErrorType>( return fetch<SuccessType, ErrorType>(
url, url,
{ {
credentials: 'include', credentials: 'include',
...(resource.endpoint ? { headers,
headers: {
...(isNeedProxy() ? { 'x-endpoint': resource.endpoint } : {}),
...(resource.needAuth ? { Authorization: cookies.get(cookies.NAMES.API_TOKEN) } : {}),
},
} : {}),
...fetchParams, ...fetchParams,
}, },
{ {
resource: resource.path, resource: resource.path,
}, },
); );
}, [ fetch ]); }, [ fetch, csrfToken ]);
} }
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
import type { CsrfData } from 'types/client/account'; import isBodyAllowed from 'lib/api/isBodyAllowed';
import type { ResourceError, ResourcePath } from 'lib/api/resources'; import type { ResourceError, ResourcePath } from 'lib/api/resources';
import { getResourceKey } from 'lib/api/useApiQuery';
export interface Params { export interface Params {
method?: RequestInit['method']; method?: RequestInit['method'];
...@@ -20,16 +17,13 @@ interface Meta { ...@@ -20,16 +17,13 @@ interface Meta {
} }
export default function useFetch() { export default function useFetch() {
const queryClient = useQueryClient();
const { token } = queryClient.getQueryData<CsrfData>(getResourceKey('csrf')) || {};
return React.useCallback(<Success, Error>(path: string, params?: Params, meta?: Meta): Promise<Success | ResourceError<Error>> => { return React.useCallback(<Success, Error>(path: string, params?: Params, meta?: Meta): Promise<Success | ResourceError<Error>> => {
const _body = params?.body; const _body = params?.body;
const isFormData = _body instanceof FormData; const isFormData = _body instanceof FormData;
const isBodyAllowed = params?.method && ![ 'GET', 'HEAD' ].includes(params.method); const withBody = isBodyAllowed(params?.method);
const body: FormData | string | undefined = (() => { const body: FormData | string | undefined = (() => {
if (!isBodyAllowed) { if (!withBody) {
return; return;
} }
...@@ -44,8 +38,7 @@ export default function useFetch() { ...@@ -44,8 +38,7 @@ export default function useFetch() {
...params, ...params,
body, body,
headers: { headers: {
...(isBodyAllowed && !isFormData ? { 'Content-type': 'application/json' } : undefined), ...(withBody && !isFormData ? { 'Content-type': 'application/json' } : undefined),
...(isBodyAllowed && token ? { 'x-csrf-token': token } : undefined),
...params?.headers, ...params?.headers,
}, },
}; };
...@@ -56,7 +49,7 @@ export default function useFetch() { ...@@ -56,7 +49,7 @@ export default function useFetch() {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
}; };
Sentry.captureException(new Error('Client fetch failed'), { extra: { ...error, ...meta }, tags: { source: 'api_fetch' } }); Sentry.captureException(new Error('Client fetch failed'), { extra: { ...error, ...meta }, tags: { source: 'fetch' } });
return response.json().then( return response.json().then(
(jsonError) => Promise.reject({ (jsonError) => Promise.reject({
...@@ -73,5 +66,5 @@ export default function useFetch() { ...@@ -73,5 +66,5 @@ export default function useFetch() {
return response.json() as Promise<Success>; return response.json() as Promise<Success>;
} }
}); });
}, [ token ]); }, [ ]);
} }
import type { ChakraProps } from '@chakra-ui/react';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
...@@ -5,15 +6,17 @@ import type { AppProps } from 'next/app'; ...@@ -5,15 +6,17 @@ import type { AppProps } from 'next/app';
import React, { useState } from 'react'; import React, { useState } from 'react';
import appConfig from 'configs/app/config'; import appConfig from 'configs/app/config';
import type { ResourceError } from 'lib/api/resources';
import { AppContextProvider } from 'lib/contexts/app'; import { AppContextProvider } from 'lib/contexts/app';
import { ChakraProvider } from 'lib/contexts/chakra'; import { ChakraProvider } from 'lib/contexts/chakra';
import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection'; import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection';
import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode'; import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
import useConfigSentry from 'lib/hooks/useConfigSentry'; import useConfigSentry from 'lib/hooks/useConfigSentry';
import { SocketProvider } from 'lib/socket/context'; import { SocketProvider } from 'lib/socket/context';
import theme from 'theme'; import theme from 'theme';
import AppError from 'ui/shared/AppError/AppError'; import AppError from 'ui/shared/AppError/AppError';
import AppErrorTooManyRequests from 'ui/shared/AppError/AppErrorTooManyRequests';
import ErrorBoundary from 'ui/shared/ErrorBoundary'; import ErrorBoundary from 'ui/shared/ErrorBoundary';
import GoogleAnalytics from 'ui/shared/GoogleAnalytics'; import GoogleAnalytics from 'ui/shared/GoogleAnalytics';
...@@ -27,33 +30,47 @@ function MyApp({ Component, pageProps }: AppProps) { ...@@ -27,33 +30,47 @@ function MyApp({ Component, pageProps }: AppProps) {
defaultOptions: { defaultOptions: {
queries: { queries: {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: (failureCount, _error) => { retry: (failureCount, error) => {
const error = _error as ResourceError<{ status: number }>; const errorPayload = getErrorObjPayload<{ status: number }>(error);
const status = error?.payload?.status || error?.status; const status = errorPayload?.status || getErrorObjStatusCode(error);
if (status && status >= 400 && status < 500) { if (status && status >= 400 && status < 500) {
// don't do retry for client error responses // don't do retry for client error responses
return false; return false;
} }
return failureCount < 2; return failureCount < 2;
}, },
useErrorBoundary: (error) => {
const status = getErrorObjStatusCode(error);
// don't catch error for "Too many requests" response
return status === 429;
},
}, },
}, },
})); }));
const renderErrorScreen = React.useCallback((error?: Error) => { const renderErrorScreen = React.useCallback((error?: Error) => {
const statusCode = getErrorCauseStatusCode(error); const statusCode = getErrorCauseStatusCode(error) || getErrorObjStatusCode(error);
const styles: ChakraProps = {
h: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
width: 'fit-content',
maxW: '800px',
margin: '0 auto',
p: { base: 4, lg: 0 },
};
if (statusCode === 429) {
return <AppErrorTooManyRequests { ...styles }/>;
}
return ( return (
<AppError <AppError
statusCode={ statusCode || 500 } statusCode={ statusCode || 500 }
height="100vh" { ...styles }
display="flex"
flexDirection="column"
alignItems="flex-start"
justifyContent="center"
width="fit-content"
margin="0 auto"
/> />
); );
}, []); }, []);
......
...@@ -5,22 +5,26 @@ import type { NextApiRequest, NextApiResponse } from 'next'; ...@@ -5,22 +5,26 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import appConfig from 'configs/app/config'; import appConfig from 'configs/app/config';
import fetchFactory from 'lib/api/nodeFetch'; import fetchFactory from 'lib/api/nodeFetch';
const handler = async(_req: NextApiRequest, res: NextApiResponse) => { const handler = async(nextReq: NextApiRequest, nextRes: NextApiResponse) => {
if (!_req.url) { if (!nextReq.url) {
res.status(500).json({ error: 'no url provided' }); nextRes.status(500).json({ error: 'no url provided' });
return; return;
} }
const url = new URL( const url = new URL(
_req.url.replace(/^\/node-api\/proxy/, ''), nextReq.url.replace(/^\/node-api\/proxy/, ''),
_req.headers['x-endpoint']?.toString() || appConfig.api.endpoint, nextReq.headers['x-endpoint']?.toString() || appConfig.api.endpoint,
); );
const response = await fetchFactory(_req)( const apiRes = await fetchFactory(nextReq)(
url.toString(), url.toString(),
_pickBy(_pick(_req, [ 'body', 'method' ]), Boolean), _pickBy(_pick(nextReq, [ 'body', 'method' ]), Boolean),
); );
res.status(response.status).send(response.body); // proxy some headers from API
nextRes.setHeader('x-request-id', apiRes.headers.get('x-request-id') || '');
nextRes.setHeader('set-cookie', apiRes.headers.get('set-cookie') || '');
nextRes.status(apiRes.status).send(apiRes.body);
}; };
export default handler; export default handler;
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import * as configs from 'playwright/utils/configs';
import AppErrorTooManyRequests from './AppErrorTooManyRequests';
test('default view +@mobile', async({ mount, page }) => {
const component = await mount(
<TestApp>
<AppErrorTooManyRequests/>
</TestApp>,
);
await page.waitForResponse('https://www.google.com/recaptcha/api2/**');
await expect(component).toHaveScreenshot({
mask: [ page.locator('.recaptcha') ],
maskColor: configs.maskColor,
});
});
import { Box, Heading, Icon, Text, chakra } from '@chakra-ui/react';
import React from 'react';
import ReCaptcha from 'react-google-recaptcha';
import appConfig from 'configs/app/config';
import icon429 from 'icons/error-pages/429.svg';
import buildUrl from 'lib/api/buildUrl';
import useFetch from 'lib/hooks/useFetch';
import useToast from 'lib/hooks/useToast';
interface Props {
className?: string;
}
const AppErrorTooManyRequests = ({ className }: Props) => {
const toast = useToast();
const fetch = useFetch();
const handleReCaptchaChange = React.useCallback(async(token: string | null) => {
if (token) {
try {
const url = buildUrl('api_v2_key');
await fetch(url, {
method: 'POST',
body: { recaptcha_response: token },
credentials: 'include',
}, {
resource: 'api_v2_key',
});
window.location.reload();
} catch (error) {
toast({
position: 'top-right',
title: 'Error',
description: 'Unable to get client key.',
status: 'error',
variant: 'subtle',
isClosable: true,
});
}
}
}, [ toast, fetch ]);
return (
<Box
className={ className }
sx={{
'.recaptcha': {
mt: 8,
h: '78px', // otherwise content will jump after reCaptcha is loaded
},
}}
>
<Icon as={ icon429 } width="200px" height="auto"/>
<Heading mt={ 8 } size="2xl" fontFamily="body">Too many requests</Heading>
<Text variant="secondary" mt={ 3 }>
You have exceeded the request rate for a given time period. Please reduce the number of requests and try again soon.
</Text>
{ appConfig.reCaptcha.siteKey && (
<ReCaptcha
className="recaptcha"
sitekey={ appConfig.reCaptcha.siteKey }
onChange={ handleReCaptchaChange }
/>
) }
</Box>
);
};
export default chakra(AppErrorTooManyRequests);
...@@ -43,7 +43,7 @@ const Page = ({ ...@@ -43,7 +43,7 @@ const Page = ({
resourceErrorPayload.message : resourceErrorPayload.message :
undefined; undefined;
const isInvalidTxHash = error?.message.includes('Invalid tx hash'); const isInvalidTxHash = error?.message?.includes('Invalid tx hash');
const isBlockConsensus = messageInPayload?.includes('Block lost consensus'); const isBlockConsensus = messageInPayload?.includes('Block lost consensus');
if (isInvalidTxHash) { if (isInvalidTxHash) {
......
...@@ -3,6 +3,8 @@ import type { StaticRoute } from 'nextjs-routes'; ...@@ -3,6 +3,8 @@ import type { StaticRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import React from 'react'; import React from 'react';
import useFetch from 'lib/hooks/useFetch';
import NftImage from './NftImage'; import NftImage from './NftImage';
import NftVideo from './NftVideo'; import NftVideo from './NftVideo';
import type { MediaType } from './utils'; import type { MediaType } from './utils';
...@@ -17,6 +19,7 @@ interface Props { ...@@ -17,6 +19,7 @@ interface Props {
const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => { const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => {
const [ type, setType ] = React.useState<MediaType | undefined>(!animationUrl ? 'image' : undefined); const [ type, setType ] = React.useState<MediaType | undefined>(!animationUrl ? 'image' : undefined);
const fetch = useFetch();
React.useEffect(() => { React.useEffect(() => {
if (!animationUrl || isLoading) { if (!animationUrl || isLoading) {
...@@ -38,7 +41,6 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => { ...@@ -38,7 +41,6 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => {
const url = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url: animationUrl } }); const url = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url: animationUrl } });
fetch(url) fetch(url)
.then((response) => response.json())
.then((_data) => { .then((_data) => {
const data = _data as { type: MediaType | undefined }; const data = _data as { type: MediaType | undefined };
setType(data.type || 'image'); setType(data.type || 'image');
...@@ -47,7 +49,7 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => { ...@@ -47,7 +49,7 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => {
setType('image'); setType('image');
}); });
}, [ animationUrl, isLoading ]); }, [ animationUrl, isLoading, fetch ]);
if (!type || isLoading) { if (!type || isLoading) {
return ( return (
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment