Add ApiError to fetch api. Add retry predicate to withRetry (#287)

This commit is contained in:
Ildar Galeev 2024-03-15 16:07:06 +07:00 committed by GitHub
parent f687f4d55d
commit e9faabd525
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 167 additions and 122 deletions

View File

@ -107,7 +107,7 @@ const provideInvoiceUnpaid = async (model: PaymentModelInvoice): Promise<Payment
} }
} }
} catch (exception) { } catch (exception) {
console.error('provideInvoiceUnpaid error:', extractError(exception)); console.error(`provideInvoiceUnpaid error: ${extractError(exception)}`);
return [ return [
{ {
name: 'paymentProcessFailed', name: 'paymentProcessFailed',

View File

@ -1,37 +1,48 @@
import { extractError } from './extractError'; import { extractError } from './extractError';
import { ApiError } from './fetchApi';
describe('extractError', () => { describe('extractError', () => {
it('should return the correct message for standard Error instances', () => { it('should extract message from Error instance', () => {
const error = new Error('Test error message'); const testError = new Error('Test error message');
expect(extractError(error)).toBe('Error: Test error message'); expect(extractError(testError)).toEqual('Error: Test error message');
}); });
it('should return the message from a custom error object with details', () => { it('should handle ApiError object with a message in details', () => {
const error = { const testApiError: ApiError = {
details: { status: 404,
message: 'Custom error message', endpoint: 'https://api.example.com/data',
}, details: { message: 'Resource not found' },
}; };
expect(extractError(error)).toBe('Custom error message'); expect(extractError(testApiError)).toEqual('Error 404 at https://api.example.com/data: Resource not found');
}); });
it('should return a default message for non-Error objects without a message', () => { it('should stringify details if message is not a string in ApiError', () => {
const error = { someProperty: 'someValue' }; const testApiError: ApiError = {
expect(extractError(error)).toBe('An unexpected error occurred.'); status: 500,
endpoint: 'https://api.example.com/data',
details: { error: 'Internal Server Error', code: 500 },
};
expect(extractError(testApiError)).toEqual(
`Error 500 at https://api.example.com/data: {"error":"Internal Server Error","code":500}`,
);
}); });
it('should handle null errors gracefully', () => { it('should return a default message for non-object errors', () => {
const error = null; expect(extractError('String error')).toEqual('An unexpected error occurred.');
expect(extractError(error)).toBe('An unexpected error occurred.'); expect(extractError(1234)).toEqual('An unexpected error occurred.');
expect(extractError(null)).toEqual('An unexpected error occurred.');
}); });
it('should handle undefined errors gracefully', () => { it('should return a default message for empty objects', () => {
const error = undefined; expect(extractError({})).toEqual('An unexpected error occurred.');
expect(extractError(error)).toBe('An unexpected error occurred.');
}); });
it('should return a default message for errors without a recognizable structure', () => { it('should handle ApiError without details gracefully', () => {
const error = 'Some string error'; const testApiError: ApiError = {
expect(extractError(error)).toBe('An unexpected error occurred.'); status: 403,
endpoint: 'https://api.example.com/data',
details: {},
};
expect(extractError(testApiError)).toEqual('Error 403 at https://api.example.com/data: {}');
}); });
}); });

View File

@ -1,18 +1,27 @@
import { ApiError } from './fetchApi';
/** /**
* Extracts a human-readable error message. * Extracts a human-readable error message.
* *
* @param {Error | { details?: { message?: string } } | unknown} error - The error to extract the message from. * @param {Error | ApiError | unknown} error - The error to extract the message from.
* @returns {string} The extracted error message. * @returns {string} The extracted error message.
*/ */
export const extractError = (error: Error | { details?: { message?: string } } | unknown): string => { export const extractError = (error: Error | ApiError | unknown): string => {
// Handling standard Error instances
if (error instanceof Error) { if (error instanceof Error) {
return `${error.name}: ${error.message}`; return `${error.name}: ${error.message}`;
} }
// Handling ApiError objects
if (typeof error === 'object' && error !== null) { if (typeof error === 'object' && error !== null) {
const message = (error as { details?: { message?: string } }).details?.message; const apiError = error as ApiError;
if (typeof message === 'string') { if ('status' in apiError && 'endpoint' in apiError && 'details' in apiError) {
return message; // Attempting to extract a meaningful message from the details object
const detailsMessage = apiError.details.message || JSON.stringify(apiError.details);
return `Error ${apiError.status} at ${apiError.endpoint}: ${detailsMessage}`;
} }
} }
// Default message for when the error cannot be identified
return 'An unexpected error occurred.'; return 'An unexpected error occurred.';
}; };

View File

@ -1,12 +1,15 @@
import { fetchApi } from './fetchApi'; import { fetchApi } from './fetchApi';
// TypeScript type assertion for mocking
declare let global: { fetch: jest.Mock };
beforeEach(() => { beforeEach(() => {
global.fetch = jest.fn(); global.fetch = jest.fn();
}); });
describe('fetchApi', () => { describe('fetchApi', () => {
it('handles successful responses correctly', async () => { it('handles successful responses correctly', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({ global.fetch.mockResolvedValueOnce({
ok: true, ok: true,
json: () => Promise.resolve({ data: 'success' }), json: () => Promise.resolve({ data: 'success' }),
}); });
@ -16,30 +19,33 @@ describe('fetchApi', () => {
expect(data).toEqual({ data: 'success' }); expect(data).toEqual({ data: 'success' });
}); });
it('throws a generic error for non-JSON error responses', async () => { it('throws an ApiError object for non-JSON error responses', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({ global.fetch.mockResolvedValueOnce({
ok: false, ok: false,
status: 500, status: 500,
// Simulating a failure in response.json() method
json: () => Promise.reject(new Error('Failed to parse JSON')), json: () => Promise.reject(new Error('Failed to parse JSON')),
}); });
await expect(fetchApi('https://api.example.com', 'token123', 'POST', 'path', {})).rejects.toThrow( await expect(fetchApi('https://api.example.com', 'token123', 'POST', 'path', {})).rejects.toEqual({
`Status: 500 Endpoint: https://api.example.com/path`, status: 500,
); endpoint: 'https://api.example.com/path',
details: {}, // Assuming failure to parse JSON results in an empty details object
});
}); });
it('throws an error with JSON error details for 400 responses', async () => { it('throws an ApiError object with JSON error details for 400 responses', async () => {
const errorDetails = { error: 'Bad Request', message: 'Invalid parameters' }; const errorDetails = { error: 'Bad Request', message: 'Invalid parameters' };
(global.fetch as jest.Mock).mockResolvedValueOnce({ global.fetch.mockResolvedValueOnce({
ok: false, ok: false,
status: 400, status: 400,
json: () => Promise.resolve({ error: 'Bad Request', message: 'Invalid parameters' }), json: () => Promise.resolve(errorDetails),
}); });
await expect(fetchApi('https://api.example.com', 'token123', 'POST', 'path', {})).rejects.toThrow( await expect(fetchApi('https://api.example.com', 'token123', 'POST', 'path', {})).rejects.toEqual({
`Status: 400 Endpoint: https://api.example.com/path ${JSON.stringify(errorDetails)}`, status: 400,
); endpoint: 'https://api.example.com/path',
details: errorDetails,
});
}); });
}); });

View File

@ -1,5 +1,11 @@
import { guid } from './guid'; import { guid } from './guid';
export type ApiError = {
status: number;
endpoint: string;
details: Record<string, any>;
};
export async function fetchApi( export async function fetchApi(
endpoint: string, endpoint: string,
accessToken: string, accessToken: string,
@ -7,7 +13,8 @@ export async function fetchApi(
path: string, path: string,
body?: any, body?: any,
): Promise<Response> { ): Promise<Response> {
const response = await fetch(`${endpoint}/${path}`, { const fullEndpoint = `${endpoint}/${path}`;
const response = await fetch(fullEndpoint, {
method, method,
headers: { headers: {
'Content-Type': 'application/json;charset=utf-8', 'Content-Type': 'application/json;charset=utf-8',
@ -18,14 +25,17 @@ export async function fetchApi(
}); });
if (!response.ok) { if (!response.ok) {
let errorDetails = `Status: ${response.status} Endpoint: ${endpoint}/${path}`; let errorDetails: Record<string, any> = {};
try { try {
const errorBody = await response.json(); errorDetails = await response.json();
errorDetails += ` ${JSON.stringify(errorBody)}`;
} catch (ex) { } catch (ex) {
// Ignore error // If the response is not in JSON format or cannot be parsed, errorDetails will remain an empty object
} }
throw new Error(errorDetails); throw {
status: response.status,
endpoint: fullEndpoint,
details: errorDetails,
} as ApiError;
} }
return response; return response;

View File

@ -1,42 +1,33 @@
import { withRetry } from './withRetry'; import { withRetry } from './withRetry';
// Mock async function that simulates varying behavior // Mock the delay function to avoid actual waiting time during tests
const createMockAsyncFunction = (shouldSucceedAfterAttempts: number, result: string, error: Error): jest.Mock => { jest.mock('./delay', () => ({
let attempts = 0; delay: jest.fn(() => Promise.resolve()),
return jest.fn(() => { }));
attempts++;
return new Promise((resolve, reject) => {
if (attempts >= shouldSucceedAfterAttempts) {
resolve(result);
} else {
reject(error);
}
});
});
};
describe('withRetry', () => { describe('withRetry', () => {
test('should succeed on first attempt', async () => { it('retries until success for recoverable errors', async () => {
const mockFn = createMockAsyncFunction(1, 'success', new Error('fail')); const mockRecoverableErrorFn = jest
const retriedFn = withRetry(mockFn, 3, 10); .fn()
.mockRejectedValueOnce(new Error('Fail')) // Fail the first time
.mockResolvedValueOnce('Recovered'); // Succeed the second time
const retriedFn = withRetry(mockRecoverableErrorFn, 2, 100);
await expect(retriedFn()).resolves.toBe('success'); await expect(retriedFn()).resolves.toEqual('Recovered');
expect(mockFn).toHaveBeenCalledTimes(1); expect(mockRecoverableErrorFn).toHaveBeenCalledTimes(2); // The function should be retried once
}); });
test('should fail after all retries', async () => { it('throws immediately on last attempt without further delay', async () => {
const mockFn = createMockAsyncFunction(5, 'success', new Error('fail')); // Succeeds after 5 attempts, but we only retry 3 times const mockFailFn = jest.fn().mockRejectedValue(new Error('Mock error'));
const retriedFn = withRetry(mockFn, 3, 10); const retriedFn = withRetry(mockFailFn, 3, 1000); // Allow for retries
await expect(retriedFn()).rejects.toThrow('fail'); const startTime = Date.now();
expect(mockFn).toHaveBeenCalledTimes(3); await expect(retriedFn()).rejects.toThrow('Mock error');
}); const endTime = Date.now();
test('should succeed after 2 retries', async () => { // Adjusting expectation due to potential slight delays in promise rejection handling
const mockFn = createMockAsyncFunction(3, 'success', new Error('fail')); // Succeeds on the third attempt // Ensure that the total execution time is significantly less than the cumulative delay that would have occurred
const retriedFn = withRetry(mockFn, 3, 10); expect(endTime - startTime).toBeLessThan(1000); // Significantly less than if it had waited after the last retry
expect(mockFailFn).toHaveBeenCalledTimes(3); // Attempted thrice
await expect(retriedFn()).resolves.toBe('success');
expect(mockFn).toHaveBeenCalledTimes(3);
}); });
}); });

View File

@ -1,15 +1,18 @@
import { delay } from './delay'; import { delay } from './delay';
/** // Defining a type for the predicate function
* Creates a retry wrapper for any async function, allowing retries on failure. type ShouldRetryPredicate = (error: any) => boolean;
*
* @param {Function} asyncFn - The asynchronous function to wrap with retry logic. // Default predicate function that allows retry only if the error is an instance of Error
* @param {number} maxRetries - The maximum number of retries. const defaultShouldRetryPredicate: ShouldRetryPredicate = (error: any) => error instanceof Error;
* @param {number} retryDelay - The initial delay between retries in milliseconds.
* @returns {Function} - A new function that wraps the original async function with retry logic.
*/
export const withRetry = export const withRetry =
<T>(asyncFn: (...args: any[]) => Promise<T>, maxRetries = 3, retryDelay = 3000) => <T>(
asyncFn: (...args: any[]) => Promise<T>,
maxRetries = 3,
retryDelay = 3000,
shouldRetryPredicate: ShouldRetryPredicate = defaultShouldRetryPredicate,
) =>
async (...args: any[]): Promise<T> => { async (...args: any[]): Promise<T> => {
let lastError: any; let lastError: any;
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 0; attempt < maxRetries; attempt++) {
@ -17,12 +20,12 @@ export const withRetry =
return await asyncFn(...args); return await asyncFn(...args);
} catch (error) { } catch (error) {
lastError = error; lastError = error;
// If this is the last attempt, throw the error immediately // If this is the last attempt or the predicate returns false, throw the error immediately
if (attempt === maxRetries - 1) { if (attempt === maxRetries - 1 || !shouldRetryPredicate(error)) {
throw lastError; throw lastError;
} }
await delay(retryDelay); await delay(retryDelay);
// Increase retryDelay for exponential backoff // Optional: Increase retryDelay for exponential backoff
retryDelay *= 2; retryDelay *= 2;
} }
} }

View File

@ -42,7 +42,7 @@ export const usePaymentCondition = (model: PaymentModel, initConditions: Payment
apiMethodCall: 1000, apiMethodCall: 1000,
}); });
} catch (exception) { } catch (exception) {
console.error(`startPayment error: ${extractError(exception)}`, exception); console.error(`startPayment error: ${extractError(exception)}`);
dispatch({ dispatch({
type: 'COMBINE_CONDITIONS', type: 'COMBINE_CONDITIONS',
payload: [ payload: [
@ -65,7 +65,7 @@ export const usePaymentCondition = (model: PaymentModel, initConditions: Payment
apiMethodCall: 3000, apiMethodCall: 3000,
}); });
} catch (exception) { } catch (exception) {
console.error(`startWaitingPaymentResult error: ${extractError(exception)}`, exception); console.error(`startWaitingPaymentResult error: ${extractError(exception)}`);
dispatch({ dispatch({
type: 'COMBINE_CONDITIONS', type: 'COMBINE_CONDITIONS',
payload: [ payload: [

View File

@ -1,13 +1,14 @@
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { Gateway } from 'checkout/backend/p2p';
import { LocaleContext, PaymentConditionsContext, PaymentContext, PaymentModelContext } from 'checkout/contexts';
import { InvoiceDetermined, PaymentStarted } from 'checkout/paymentCondition';
import { isNil } from 'checkout/utils';
import { CompletePayment } from './CompletePayment'; import { CompletePayment } from './CompletePayment';
import { Destinations } from './Destinations'; import { Destinations } from './Destinations';
import { GatewaySelector } from './GatewaySelector'; import { GatewaySelector } from './GatewaySelector';
import { Gateway } from '../../../common/backend/p2p';
import { LocaleContext, PaymentConditionsContext, PaymentContext, PaymentModelContext } from '../../../common/contexts';
import { InvoiceDetermined, PaymentStarted } from '../../../common/paymentCondition';
import { isNil } from '../../../common/utils';
import { FormLoader } from '../../../components/legacy'; import { FormLoader } from '../../../components/legacy';
const FormContainer = styled.div` const FormContainer = styled.div`

View File

@ -1,10 +1,11 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { Locale } from 'checkout/contexts';
import { isNil } from 'checkout/utils';
import { Info } from './commonComponents'; import { Info } from './commonComponents';
import { useComplete } from './useComplete'; import { useComplete } from './useComplete';
import { Locale } from '../../../common/contexts';
import { isNil } from '../../../common/utils';
import { Button } from '../../../components/legacy'; import { Button } from '../../../components/legacy';
const Container = styled.div` const Container = styled.div`

View File

@ -1,11 +1,12 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { Destination } from 'checkout/backend/p2p';
import { Locale } from 'checkout/contexts';
import { ViewModelContext } from 'checkout/contexts';
import { DestinationInfoBankAccount } from './DestinationInfoBankAccount'; import { DestinationInfoBankAccount } from './DestinationInfoBankAccount';
import { DestinationInfoBankCard } from './DestinationInfoBankCard'; import { DestinationInfoBankCard } from './DestinationInfoBankCard';
import { DestinationInfoSpb } from './DestinationInfoSpb'; import { DestinationInfoSpb } from './DestinationInfoSpb';
import { Destination } from '../../../../common/backend/p2p';
import { Locale } from '../../../../common/contexts';
import { ViewModelContext } from '../../../../common/contexts';
import { Info, Container, Row, Label, Value, Alert } from '../commonComponents'; import { Info, Container, Row, Label, Value, Alert } from '../commonComponents';
type DestinationInfoProps = { type DestinationInfoProps = {

View File

@ -1,6 +1,7 @@
import { DestinationBankAccount } from 'checkout/backend/p2p';
import { Locale } from 'checkout/contexts';
import { CopyToClipboard } from './CopyToClipboard'; import { CopyToClipboard } from './CopyToClipboard';
import { DestinationBankAccount } from '../../../../common/backend/p2p';
import { Locale } from '../../../../common/contexts';
import { Container, Label, Row, Value } from '../commonComponents'; import { Container, Label, Row, Value } from '../commonComponents';
export type DestinationInfoBankCardInfo = { export type DestinationInfoBankCardInfo = {

View File

@ -1,6 +1,7 @@
import { DestinationBankCard } from 'checkout/backend/p2p';
import { Locale } from 'checkout/contexts';
import { CopyToClipboard } from './CopyToClipboard'; import { CopyToClipboard } from './CopyToClipboard';
import { DestinationBankCard } from '../../../../common/backend/p2p';
import { Locale } from '../../../../common/contexts';
import { Container, Label, Row, Value } from '../commonComponents'; import { Container, Label, Row, Value } from '../commonComponents';
export type DestinationInfoBankCardInfo = { export type DestinationInfoBankCardInfo = {

View File

@ -1,6 +1,7 @@
import { DestinationSBP } from 'checkout/backend/p2p';
import { Locale } from 'checkout/contexts';
import { CopyToClipboard } from './CopyToClipboard'; import { CopyToClipboard } from './CopyToClipboard';
import { DestinationSBP } from '../../../../common/backend/p2p';
import { Locale } from '../../../../common/contexts';
import { Container, Label, Row, Value } from '../commonComponents'; import { Container, Label, Row, Value } from '../commonComponents';
export type DestinationInfoSpbProps = { export type DestinationInfoSpbProps = {

View File

@ -1,9 +1,10 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Locale } from 'checkout/contexts';
import { isNil } from 'checkout/utils';
import { DestinationInfo } from './DestinationInfo'; import { DestinationInfo } from './DestinationInfo';
import { useDestinations } from './useDestinations'; import { useDestinations } from './useDestinations';
import { Locale } from '../../../common/contexts';
import { isNil } from '../../../common/utils';
export type DestinationsProps = { export type DestinationsProps = {
locale: Locale; locale: Locale;

View File

@ -1,8 +1,9 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Gateway } from 'checkout/backend/p2p';
import { Locale } from 'checkout/contexts';
import { useGateways } from './useGateways'; import { useGateways } from './useGateways';
import { Gateway } from '../../../common/backend/p2p';
import { Locale } from '../../../common/contexts';
import { Select } from '../../../components/legacy'; import { Select } from '../../../components/legacy';
export type GatewaySelectorProps = { export type GatewaySelectorProps = {

View File

@ -1,7 +1,7 @@
import { useCallback, useReducer } from 'react'; import { useCallback, useReducer } from 'react';
import { complete as completeApi } from '../../../common/backend/p2p'; import { complete as completeApi } from 'checkout/backend/p2p';
import { extractError, withRetry } from '../../../common/utils'; import { extractError, withRetry } from 'checkout/utils';
type State = { status: 'PRISTINE' | 'LOADING' | 'SUCCESS' | 'FAILURE' }; type State = { status: 'PRISTINE' | 'LOADING' | 'SUCCESS' | 'FAILURE' };

View File

@ -1,7 +1,7 @@
import { useCallback, useReducer, useRef } from 'react'; import { useCallback, useReducer, useRef } from 'react';
import { Destination, getDestinations as getApiDestinations } from '../../../common/backend/p2p'; import { Destination, getDestinations as getApiDestinations } from 'checkout/backend/p2p';
import { extractError, withRetry } from '../../../common/utils'; import { extractError, withRetry } from 'checkout/utils';
type State = { status: 'PRISTINE' | 'LOADING' | 'FAILURE' } | { status: 'SUCCESS'; data: Destination[] }; type State = { status: 'PRISTINE' | 'LOADING' | 'FAILURE' } | { status: 'SUCCESS'; data: Destination[] };
@ -52,7 +52,10 @@ export const useDestinations = (
dispatch({ type: 'FETCH_SUCCESS', payload: destinations }); dispatch({ type: 'FETCH_SUCCESS', payload: destinations });
} catch (error) { } catch (error) {
dispatch({ type: 'FETCH_FAILURE' }); dispatch({ type: 'FETCH_FAILURE' });
console.error(`Failed to fetch destinations. ${extractError(error)}`); // Api returns 500 error when there are no no requisites available.
if ('status' in error && error.status !== 500) {
console.error(`Failed to fetch destinations. ${extractError(error)}`);
}
} }
}, [capiEndpoint, accessToken, invoiceID, paymentID, gatewayID]); }, [capiEndpoint, accessToken, invoiceID, paymentID, gatewayID]);

View File

@ -1,7 +1,7 @@
import { useCallback, useReducer } from 'react'; import { useCallback, useReducer } from 'react';
import { Gateway, getGateways as getApiGateways } from '../../../common/backend/p2p'; import { Gateway, getGateways as getApiGateways } from 'checkout/backend/p2p';
import { extractError, withRetry } from '../../../common/utils'; import { extractError, withRetry } from 'checkout/utils';
type State = { status: 'PRISTINE' | 'LOADING' | 'FAILURE' } | { status: 'SUCCESS'; data: Gateway[] }; type State = { status: 'PRISTINE' | 'LOADING' | 'FAILURE' } | { status: 'SUCCESS'; data: Gateway[] };

View File

@ -1,15 +1,16 @@
import { useContext, useEffect } from 'react'; import { useContext, useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { IconName } from './types';
import { getResultInfo } from './utils';
import { import {
CompletePaymentContext, CompletePaymentContext,
LocaleContext, LocaleContext,
PaymentConditionsContext, PaymentConditionsContext,
PaymentModelContext, PaymentModelContext,
} from '../../../common/contexts'; } from 'checkout/contexts';
import { isNil, last } from '../../../common/utils'; import { isNil, last } from 'checkout/utils';
import { IconName } from './types';
import { getResultInfo } from './utils';
import { Button, ErrorIcon, Link, SuccessIcon, WarningIcon } from '../../../components/legacy'; import { Button, ErrorIcon, Link, SuccessIcon, WarningIcon } from '../../../components/legacy';
const Wrapper = styled.div` const Wrapper = styled.div`

View File

@ -16,7 +16,10 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"baseUrl": "." "baseUrl": ".",
"paths": {
"checkout/*": ["./src/common/*"]
}
}, },
"include": ["./src/**/*", "./types/**/*"] "include": ["./src/**/*", "./types/**/*"]
} }