Skip to content

add timeout to getApiResponse #8

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 21, 2024
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ If you prefer Tailwind css, check this: [Tailwind-CSS-Version](https://github.co

## Demo

[<img src="./public/images/cover.png">](https://mui-nextjs-ts.vercel.app)
[<img src="https://alexstack.github.io/reactStarter/asset/NextJs14-mui5.gif">](https://mui-nextjs-ts.vercel.app)

🚘🚘🚘 [**Click here to see an online demo**](https://mui-nextjs-ts.vercel.app) 🚘🚘🚘

Expand Down
2 changes: 2 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ const loadDataFromApi = async (slug?: string) => {
getApiResponse<NpmData>({
apiEndpoint: 'https://registry.npmjs.org/react/latest',
revalidate: 60 * 60 * 24, // 24 hours cache
timeout: 5000, // 5 seconds
}),
getApiResponse<NpmData>({
apiEndpoint: 'https://registry.npmjs.org/next/latest',
revalidate: 0, // no cache
timeout: 5000, // 5 seconds
}),
]);

Expand Down
85 changes: 76 additions & 9 deletions src/components/shared/DisplayRandomPicture.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/* eslint-disable @next/next/no-img-element */

'use client';
import { Send } from '@mui/icons-material';
import styled from '@emotion/styled';
import { Autorenew, Send } from '@mui/icons-material';
import { css, keyframes } from '@mui/material';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import { purple } from '@mui/material/colors';
import Stack from '@mui/material/Stack';
import React, { useEffect, useState } from 'react';

Expand All @@ -12,42 +15,60 @@ import { useClientContext } from '@/hooks/useClientContext';

import SubmitButton from '@/components/shared/SubmitButton';

import { getApiResponse } from '@/utils/shared/get-api-response';

const DisplayRandomPicture = () => {
const [imageUrl, setImageUrl] = useState('');
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const { fetchCount, updateClientCtx } = useClientContext();
const { setAlertBarProps, renderAlertBar } = useAlertBar();
const renderCountRef = React.useRef(0);

const fetchRandomPicture = async () => {
if (loading) {
setAlertBarProps({
message: 'Please wait for the current fetch to complete',
severity: 'warning',
});
return;
}
setLoading(true);
setError('');

try {
const response = await fetch('https://picsum.photos/300/150');
if (!response.ok) {
throw new Error('Error fetching the image');
const response = await getApiResponse<Response & { url: string }>({
apiEndpoint: 'https://picsum.photos/300/160',
timeout: 5001,
});

if (!response?.url) {
throw new Error('Error fetching the image, no response url');
}

setImageUrl(response.url);
updateClientCtx({ fetchCount: fetchCount + 1 });
setAlertBarProps({
message: 'A random picture fetched successfully',
severity: 'info',
});
} catch (error) {
setError('Error fetching the image');
const errorMsg =
error instanceof Error ? error.message : 'Error fetching the image';

setError(errorMsg);
setAlertBarProps({
message: 'Error fetching the image',
message: errorMsg,
severity: 'error',
});
setLoading(false);
} finally {
setLoading(false);
}
};

useEffect(() => {
if (renderCountRef.current === 0) {
if (renderCountRef.current === 0 && !loading) {
fetchRandomPicture();
}
renderCountRef.current += 1;
Expand All @@ -59,6 +80,7 @@ const DisplayRandomPicture = () => {
justifyContent='center'
alignItems='center'
spacing={2}
sx={{ position: 'relative', width: '300px', margin: '0 auto' }}
>
{error && <p>{error}</p>}
{imageUrl && (
Expand All @@ -71,7 +93,7 @@ const DisplayRandomPicture = () => {
)}
<div>
{loading && <span>Loading...</span>} Component Render Count:{' '}
{renderCountRef.current}
{renderCountRef.current + 1}
</div>

<SubmitButton
Expand All @@ -88,9 +110,54 @@ const DisplayRandomPicture = () => {
Get Another Picture
</Button>
</SubmitButton>
{imageUrl && (
<StyledRefreshButton onClick={fetchRandomPicture} loading={loading}>
<Avatar sx={{ width: 24, height: 24 }}>
<Autorenew />
</Avatar>
</StyledRefreshButton>
)}
{renderAlertBar()}
</Stack>
);
};

const spin = keyframes`
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
`;
const StyledRefreshButton = styled.div<{ loading?: boolean }>`
position: absolute;
right: 0;
top: 0;
margin: 0.5rem !important;
pointer-events: ${({ loading }) => (loading ? 'none' : 'auto')};
opacity: ${({ loading }) => (loading ? '0.6' : '1')};
cursor: ${({ loading }) => (loading ? 'not-allowed' : 'pointer')};
svg {
width: 20px;
height: 20px;
animation: ${({ loading }) =>
loading
? css`
${spin} 2s linear infinite
`
: 'none'};
}
:hover {
svg {
path {
fill: ${purple[500]};
}
}
.MuiAvatar-circular {
background-color: ${purple[50]};
}
}
`;

export default DisplayRandomPicture;
31 changes: 30 additions & 1 deletion src/utils/shared/get-api-response.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,48 @@
import { IS_PROD } from '@/constants';
import { consoleLog } from '@/utils/shared/console-log';

/**
* Makes an API request and returns the response data.
*
* @param apiEndpoint - The API endpoint URL.
* @param requestData - The request data to be sent in the request body.
* @param method - The HTTP method for the request (default: 'GET').
* @param revalidate - The time in seconds to cache the data (default: 3600 seconds in production, 120 seconds otherwise).
* @param headers - The headers to be included in the request.
* @param timeout - The timeout in milliseconds for the request (default: 100000 = 100 seconds).
* @returns The response data from the API.
* @throws An error if the API request fails or times out.
*/
export const getApiResponse = async <T>({
apiEndpoint,
requestData,
method = 'GET',
revalidate = IS_PROD ? 3600 : 120, // cache data in seconds
headers,
timeout = 100000, // 100 seconds
}: {
apiEndpoint: string;
requestData?: BodyInit;
method?: 'POST' | 'GET' | 'PUT' | 'DELETE';
revalidate?: number;
headers?: HeadersInit;
timeout?: number;
}) => {
try {
const startTime = Date.now();
const controller = new AbortController();
const signal = controller.signal;

const timeoutId = setTimeout(() => controller.abort(), timeout);

const response = await fetch(apiEndpoint, {
method,
body: requestData,
headers,
next: {
revalidate,
},
signal,
});
if (!response.ok) {
consoleLog('🚀 Debug getApiResponse requestData:', requestData);
Expand All @@ -38,9 +58,18 @@ export const getApiResponse = async <T>({
duration > 2000 ? '💔' : '-'
} ${apiEndpoint}`
);

clearTimeout(timeoutId);
// if is not valid JSON, return response
if (!response.headers.get('content-type')?.includes('application/json')) {
return response as T;
}
return (await response.json()) as T;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(
'Fetch request timed out: ' + (timeout / 1000).toFixed(1) + ' s'
);
}
consoleLog('getApiResponse error:', error);
}

Expand Down
Loading