Skip to content

Commit

Permalink
support 429 "Too many requests" API error (blockscout#1004)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
tom2drum authored Aug 2, 2023
1 parent b38b5a8 commit 908543b
Show file tree
Hide file tree
Showing 17 changed files with 185 additions and 45 deletions.
1 change: 1 addition & 0 deletions configs/app/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ const config = Object.freeze({
host: appHost,
port: appPort,
baseUrl,
useNextJsProxy: getEnvValue(process.env.NEXT_PUBLIC_USE_NEXT_JS_PROXY) === 'true',
},
ad: {
adBannerProvider: getAdBannerProvider(),
Expand Down
1 change: 1 addition & 0 deletions docs/ENVS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_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_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

Expand Down
2 changes: 1 addition & 1 deletion icons/email-sent.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions icons/error-pages/429.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions lib/api/isBodyAllowed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function isBodyAllowed(method: string | undefined | null) {
return method && ![ 'GET', 'HEAD' ].includes(method);
}
4 changes: 4 additions & 0 deletions lib/api/isNeedProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@ import appConfig from 'configs/app/config';
// unsuccessfully tried different ways, even custom local dev domain
// so for local development we have to use next.js api as proxy server
export default function isNeedProxy() {
if (appConfig.app.useNextJsProxy) {
return true;
}

return appConfig.app.host === 'localhost' && appConfig.app.host !== appConfig.api.host;
}
5 changes: 5 additions & 0 deletions lib/api/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,11 @@ export const RESOURCES = {
path: '/api/v2/config/backend-version',
},

// OTHER
api_v2_key: {
path: '/api/v2/key',
},

// DEPRECATED
old_api: {
path: '/api',
Expand Down
26 changes: 19 additions & 7 deletions lib/api/useApiFetch.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { useQueryClient } from '@tanstack/react-query';
import _pickBy from 'lodash/pickBy';
import React from 'react';

import type { CsrfData } from 'types/client/account';

import isBodyAllowed from 'lib/api/isBodyAllowed';
import isNeedProxy from 'lib/api/isNeedProxy';
import { getResourceKey } from 'lib/api/useApiQuery';
import * as cookies from 'lib/cookies';
import type { Params as FetchParams } from 'lib/hooks/useFetch';
import useFetch from 'lib/hooks/useFetch';
Expand All @@ -17,28 +23,34 @@ export interface Params<R extends ResourceName> {

export default function useApiFetch() {
const fetch = useFetch();
const queryClient = useQueryClient();
const { token: csrfToken } = queryClient.getQueryData<CsrfData>(getResourceKey('csrf')) || {};

return React.useCallback(<R extends ResourceName, SuccessType = unknown, ErrorType = unknown>(
resourceName: R,
{ pathParams, queryParams, fetchParams }: Params<R> = {},
) => {
const apiToken = cookies.get(cookies.NAMES.API_TOKEN);

const resource: ApiResource = RESOURCES[resourceName];
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>(
url,
{
credentials: 'include',
...(resource.endpoint ? {
headers: {
...(isNeedProxy() ? { 'x-endpoint': resource.endpoint } : {}),
...(resource.needAuth ? { Authorization: cookies.get(cookies.NAMES.API_TOKEN) } : {}),
},
} : {}),
headers,
...fetchParams,
},
{
resource: resource.path,
},
);
}, [ fetch ]);
}, [ fetch, csrfToken ]);
}
19 changes: 6 additions & 13 deletions lib/hooks/useFetch.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import * as Sentry from '@sentry/react';
import { useQueryClient } from '@tanstack/react-query';
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 { getResourceKey } from 'lib/api/useApiQuery';

export interface Params {
method?: RequestInit['method'];
Expand All @@ -20,16 +17,13 @@ interface Meta {
}

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>> => {
const _body = params?.body;
const isFormData = _body instanceof FormData;
const isBodyAllowed = params?.method && ![ 'GET', 'HEAD' ].includes(params.method);
const withBody = isBodyAllowed(params?.method);

const body: FormData | string | undefined = (() => {
if (!isBodyAllowed) {
if (!withBody) {
return;
}

Expand All @@ -44,8 +38,7 @@ export default function useFetch() {
...params,
body,
headers: {
...(isBodyAllowed && !isFormData ? { 'Content-type': 'application/json' } : undefined),
...(isBodyAllowed && token ? { 'x-csrf-token': token } : undefined),
...(withBody && !isFormData ? { 'Content-type': 'application/json' } : undefined),
...params?.headers,
},
};
Expand All @@ -56,7 +49,7 @@ export default function useFetch() {
status: response.status,
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(
(jsonError) => Promise.reject({
Expand All @@ -73,5 +66,5 @@ export default function useFetch() {
return response.json() as Promise<Success>;
}
});
}, [ token ]);
}, [ ]);
}
43 changes: 30 additions & 13 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import type { ChakraProps } from '@chakra-ui/react';
import * as Sentry from '@sentry/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import type { AppProps } from 'next/app';
import React, { useState } from 'react';

import appConfig from 'configs/app/config';
import type { ResourceError } from 'lib/api/resources';
import { AppContextProvider } from 'lib/contexts/app';
import { ChakraProvider } from 'lib/contexts/chakra';
import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection';
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 { SocketProvider } from 'lib/socket/context';
import theme from 'theme';
import AppError from 'ui/shared/AppError/AppError';
import AppErrorTooManyRequests from 'ui/shared/AppError/AppErrorTooManyRequests';
import ErrorBoundary from 'ui/shared/ErrorBoundary';
import GoogleAnalytics from 'ui/shared/GoogleAnalytics';

Expand All @@ -27,33 +30,47 @@ function MyApp({ Component, pageProps }: AppProps) {
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: (failureCount, _error) => {
const error = _error as ResourceError<{ status: number }>;
const status = error?.payload?.status || error?.status;
retry: (failureCount, error) => {
const errorPayload = getErrorObjPayload<{ status: number }>(error);
const status = errorPayload?.status || getErrorObjStatusCode(error);
if (status && status >= 400 && status < 500) {
// don't do retry for client error responses
return false;
}

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 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 (
<AppError
statusCode={ statusCode || 500 }
height="100vh"
display="flex"
flexDirection="column"
alignItems="flex-start"
justifyContent="center"
width="fit-content"
margin="0 auto"
{ ...styles }
/>
);
}, []);
Expand Down
20 changes: 12 additions & 8 deletions pages/api/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,26 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import appConfig from 'configs/app/config';
import fetchFactory from 'lib/api/nodeFetch';

const handler = async(_req: NextApiRequest, res: NextApiResponse) => {
if (!_req.url) {
res.status(500).json({ error: 'no url provided' });
const handler = async(nextReq: NextApiRequest, nextRes: NextApiResponse) => {
if (!nextReq.url) {
nextRes.status(500).json({ error: 'no url provided' });
return;
}

const url = new URL(
_req.url.replace(/^\/node-api\/proxy/, ''),
_req.headers['x-endpoint']?.toString() || appConfig.api.endpoint,
nextReq.url.replace(/^\/node-api\/proxy/, ''),
nextReq.headers['x-endpoint']?.toString() || appConfig.api.endpoint,
);
const response = await fetchFactory(_req)(
const apiRes = await fetchFactory(nextReq)(
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;
21 changes: 21 additions & 0 deletions ui/shared/AppError/AppErrorTooManyRequests.pw.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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,
});
});
74 changes: 74 additions & 0 deletions ui/shared/AppError/AppErrorTooManyRequests.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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);
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion ui/shared/Page/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const Page = ({
resourceErrorPayload.message :
undefined;

const isInvalidTxHash = error?.message.includes('Invalid tx hash');
const isInvalidTxHash = error?.message?.includes('Invalid tx hash');
const isBlockConsensus = messageInPayload?.includes('Block lost consensus');

if (isInvalidTxHash) {
Expand Down
Loading

0 comments on commit 908543b

Please sign in to comment.