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) {
console.error('provideInvoiceUnpaid error:', extractError(exception));
console.error(`provideInvoiceUnpaid error: ${extractError(exception)}`);
return [
{
name: 'paymentProcessFailed',

View File

@ -1,37 +1,48 @@
import { extractError } from './extractError';
import { ApiError } from './fetchApi';
describe('extractError', () => {
it('should return the correct message for standard Error instances', () => {
const error = new Error('Test error message');
expect(extractError(error)).toBe('Error: Test error message');
it('should extract message from Error instance', () => {
const testError = new Error('Test error message');
expect(extractError(testError)).toEqual('Error: Test error message');
});
it('should return the message from a custom error object with details', () => {
const error = {
details: {
message: 'Custom error message',
},
it('should handle ApiError object with a message in details', () => {
const testApiError: ApiError = {
status: 404,
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', () => {
const error = { someProperty: 'someValue' };
expect(extractError(error)).toBe('An unexpected error occurred.');
it('should stringify details if message is not a string in ApiError', () => {
const testApiError: ApiError = {
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', () => {
const error = null;
expect(extractError(error)).toBe('An unexpected error occurred.');
it('should return a default message for non-object errors', () => {
expect(extractError('String error')).toEqual('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', () => {
const error = undefined;
expect(extractError(error)).toBe('An unexpected error occurred.');
it('should return a default message for empty objects', () => {
expect(extractError({})).toEqual('An unexpected error occurred.');
});
it('should return a default message for errors without a recognizable structure', () => {
const error = 'Some string error';
expect(extractError(error)).toBe('An unexpected error occurred.');
it('should handle ApiError without details gracefully', () => {
const testApiError: ApiError = {
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.
*
* @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.
*/
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) {
return `${error.name}: ${error.message}`;
}
// Handling ApiError objects
if (typeof error === 'object' && error !== null) {
const message = (error as { details?: { message?: string } }).details?.message;
if (typeof message === 'string') {
return message;
const apiError = error as ApiError;
if ('status' in apiError && 'endpoint' in apiError && 'details' in apiError) {
// 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.';
};

View File

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

View File

@ -1,5 +1,11 @@
import { guid } from './guid';
export type ApiError = {
status: number;
endpoint: string;
details: Record<string, any>;
};
export async function fetchApi(
endpoint: string,
accessToken: string,
@ -7,7 +13,8 @@ export async function fetchApi(
path: string,
body?: any,
): Promise<Response> {
const response = await fetch(`${endpoint}/${path}`, {
const fullEndpoint = `${endpoint}/${path}`;
const response = await fetch(fullEndpoint, {
method,
headers: {
'Content-Type': 'application/json;charset=utf-8',
@ -18,14 +25,17 @@ export async function fetchApi(
});
if (!response.ok) {
let errorDetails = `Status: ${response.status} Endpoint: ${endpoint}/${path}`;
let errorDetails: Record<string, any> = {};
try {
const errorBody = await response.json();
errorDetails += ` ${JSON.stringify(errorBody)}`;
errorDetails = await response.json();
} 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;

View File

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

View File

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

View File

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

View File

@ -1,13 +1,14 @@
import { useContext, useEffect, useMemo, useState } from 'react';
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 { Destinations } from './Destinations';
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';
const FormContainer = styled.div`

View File

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

View File

@ -1,11 +1,12 @@
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 { DestinationInfoBankCard } from './DestinationInfoBankCard';
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';
type DestinationInfoProps = {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { useCallback, useReducer, useRef } from 'react';
import { Destination, getDestinations as getApiDestinations } from '../../../common/backend/p2p';
import { extractError, withRetry } from '../../../common/utils';
import { Destination, getDestinations as getApiDestinations } from 'checkout/backend/p2p';
import { extractError, withRetry } from 'checkout/utils';
type State = { status: 'PRISTINE' | 'LOADING' | 'FAILURE' } | { status: 'SUCCESS'; data: Destination[] };
@ -52,7 +52,10 @@ export const useDestinations = (
dispatch({ type: 'FETCH_SUCCESS', payload: destinations });
} catch (error) {
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]);

View File

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

View File

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

View File

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