Skip to content

Commit

Permalink
feat(loader): add retry logic (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
faris-imi authored Dec 4, 2023
1 parent d804d14 commit ead10d6
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 22 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# hCaptcha Loader

This is a JavaScript library to easily configure the loading of the [hCaptcha](https://www.hcaptcha.com) JS client SDK with built-in error handling.
This is a JavaScript library to easily configure the loading of the [hCaptcha](https://www.hcaptcha.com) JS client SDK with built-in error handling. It also includes a retry mechanism that will attempt to load the hCaptcha script several times in the event if fails to load due to a network or unforeseen issue.

> [hCaptcha](https://www.hcaptcha.com) is a drop-replacement for reCAPTCHA that protects user privacy.
>
Expand Down
70 changes: 62 additions & 8 deletions lib/__test__/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { waitFor } from '@testing-library/dom';

import { fetchScript } from "../src/script";
import { hCaptchaLoader, hCaptchaScripts } from "../src/loader";
import { HCAPTCHA_LOAD_FN_NAME, SCRIPT_COMPLETE, SCRIPT_ERROR} from "../src/constants";
import { HCAPTCHA_LOAD_FN_NAME, SCRIPT_COMPLETE, SCRIPT_ERROR } from "../src/constants";

jest.mock('../src/script');

Expand Down Expand Up @@ -38,7 +38,7 @@ describe('hCaptchaLoader', () => {
const promise = hCaptchaLoader({ sentry: false });

await waitFor(() => {
expect(mockFetchScript).toHaveBeenCalled();
expect(mockFetchScript).toHaveBeenCalledTimes(1);

// Trigger script onload callback to resolve promise
window[HCAPTCHA_LOAD_FN_NAME]();
Expand All @@ -47,26 +47,80 @@ describe('hCaptchaLoader', () => {
});

it('should not fetch script since it was already loaded', async () => {
const result = await hCaptchaLoader({ sentry: false });
expect(result).toEqual(window.hcaptcha);
expect(mockFetchScript).not.toHaveBeenCalled();
const result = await hCaptchaLoader({ sentry: false });
expect(result).toEqual(window.hcaptcha);
expect(mockFetchScript).not.toHaveBeenCalled();
});

});

describe('script retry', () => {

beforeAll(() => {
window.hcaptcha = 'hcaptcha-test';
})

afterEach(() => {
jest.resetAllMocks();
cleanupScripts();
});

it('should retry and load after fetch script error', async () => {
mockFetchScript.mockRejectedValueOnce(SCRIPT_ERROR);
mockFetchScript.mockResolvedValueOnce(SCRIPT_COMPLETE);

const promise = hCaptchaLoader({ sentry: false });

await waitFor(() => {
expect(mockFetchScript).toHaveBeenCalledTimes(2);

// Trigger script onload callback to resolve promise
window[HCAPTCHA_LOAD_FN_NAME]();
expect(promise).resolves.toEqual(window.hcaptcha);
});
});

it('should try loading 2 times and succeed on final try', async () => {
mockFetchScript.mockRejectedValueOnce(SCRIPT_ERROR);
mockFetchScript.mockRejectedValueOnce(SCRIPT_ERROR);
mockFetchScript.mockResolvedValueOnce(SCRIPT_COMPLETE);

const promise = hCaptchaLoader({ sentry: false });

await waitFor(() => {
expect(mockFetchScript).toHaveBeenCalledTimes(3);

// Trigger script onload callback to resolve promise
window[HCAPTCHA_LOAD_FN_NAME]();
expect(promise).resolves.toEqual(window.hcaptcha);
});
});

it('should try loading 3 times and throw', async () => {
mockFetchScript.mockRejectedValue('test error');

try {
await hCaptchaLoader({ sentry: false, cleanup: true });
} catch (error) {
expect(mockFetchScript).toBeCalledTimes(3);
expect(error.message).toBe(SCRIPT_ERROR);
}
});
})

describe('script error', () => {

afterEach(() => {
cleanupScripts();
jest.resetAllMocks();
})

it('should reject with script-error when error while loading occurs', async () => {
mockFetchScript.mockRejectedValueOnce(SCRIPT_ERROR);
mockFetchScript.mockRejectedValue(SCRIPT_ERROR);

try {
await hCaptchaLoader({ sentry: false });
} catch(error) {
expect(error.message).toEqual(SCRIPT_ERROR)
expect(error.message).toEqual(SCRIPT_ERROR);
}
});

Expand Down
6 changes: 5 additions & 1 deletion lib/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export const SCRIPT_ID = 'hCaptcha-script';
export const HCAPTCHA_LOAD_FN_NAME = 'hCaptchaOnLoad';
export const SCRIPT_ERROR = 'script-error';
export const SCRIPT_COMPLETE = 'script-loaded';
export const SCRIPT_COMPLETE = 'script-loaded';

export const SENTRY_TAG = '@hCaptcha/loader';

export const MAX_RETRIES = 2;
53 changes: 42 additions & 11 deletions lib/src/loader.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
import { generateQuery, getFrame, getMountElement } from './utils';
import { HCAPTCHA_LOAD_FN_NAME, SCRIPT_ERROR } from './constants';
import { HCAPTCHA_LOAD_FN_NAME, MAX_RETRIES, SCRIPT_ERROR, SENTRY_TAG } from './constants';
import { initSentry } from './sentry';
import { fetchScript } from './script';

import type { ILoaderParams } from './types';
import type { ILoaderParams, SentryHub } from './types';

// Prevent loading API script multiple times
export const hCaptchaScripts = [];

// Generate hCaptcha API script
export function hCaptchaLoader(params: ILoaderParams = { cleanup: true }): Promise<any> {
const sentry = initSentry(params.sentry);
export function hCaptchaApi(params: ILoaderParams = { cleanup: true }, sentry: SentryHub): Promise<any> {

try {

sentry.addBreadcrumb({
category: 'script',
category: SENTRY_TAG,
message: 'hCaptcha loader params',
data: params,
});
Expand All @@ -26,7 +25,7 @@ export function hCaptchaLoader(params: ILoaderParams = { cleanup: true }): Promi

if (script) {
sentry.addBreadcrumb({
category: 'script',
category: SENTRY_TAG,
message: 'hCaptcha already loaded',
});

Expand All @@ -42,7 +41,7 @@ export function hCaptchaLoader(params: ILoaderParams = { cleanup: true }): Promi
// Create global onload callback for the hCaptcha library to call
frame.window[HCAPTCHA_LOAD_FN_NAME] = () => {
sentry.addBreadcrumb({
category: 'hCaptcha:script',
category: SENTRY_TAG,
message: 'hCaptcha script called onload function',
});

Expand All @@ -66,12 +65,15 @@ export function hCaptchaLoader(params: ILoaderParams = { cleanup: true }): Promi
await fetchScript({ query, ...params });

sentry.addBreadcrumb({
category: 'hCaptcha:script',
category: SENTRY_TAG,
message: 'hCaptcha loaded',
data: script
});

hCaptchaScripts.push({ promise, scope: frame.window });
} catch(error) {
sentry.addBreadcrumb({
category: 'hCaptcha:script',
category: SENTRY_TAG,
message: 'hCaptcha failed to load',
data: error,
});
Expand All @@ -82,11 +84,40 @@ export function hCaptchaLoader(params: ILoaderParams = { cleanup: true }): Promi
}
);

hCaptchaScripts.push({ promise, scope: frame.window });

return promise;
} catch (error) {
sentry.captureException(error);
return Promise.reject(new Error(SCRIPT_ERROR));
}
}

export async function loadScript(params, retries = 0) {
const message = retries < MAX_RETRIES ? 'Retry loading hCaptcha Api' : 'Exceeded maximum retries';

const sentry = initSentry(params.sentry);

try {

return await hCaptchaApi(params, sentry);
} catch (error) {

sentry.addBreadcrumb({
SENTRY_SOURCE: SENTRY_TAG,
message,
data: { error }
});

if (retries >= MAX_RETRIES) {
sentry.captureException(error);
return Promise.reject(error);
} else {
retries += 1;
return loadScript(params, retries);
}
}
}


export function hCaptchaLoader(params) {
return loadScript(params);
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@hcaptcha/loader",
"description": "This is a JavaScript library to easily configure the loading of the hCaptcha JS client SDK with built-in error handling.",
"version": "1.0.10",
"version": "1.1.0",
"author": "hCaptcha team and contributors",
"license": "MIT",
"keywords": [
Expand Down

0 comments on commit ead10d6

Please sign in to comment.