Skip to content
This repository has been archived by the owner on May 3, 2024. It is now read-only.

feat(createRequestHtmlFragment): implemented circuit breaker #111

Merged
merged 9 commits into from
May 1, 2020
2 changes: 1 addition & 1 deletion __tests__/integration/one-app.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('Tests that require Docker setup', () => {
expect(rawHeaders).not.toHaveProperty('access-control-max-age');
expect(rawHeaders).not.toHaveProperty('access-control-allow-methods');
expect(rawHeaders).not.toHaveProperty('access-control-allow-headers');
// any respnse headers
// any response headers
expect(rawHeaders).not.toHaveProperty('access-control-allow-origin');
expect(rawHeaders).not.toHaveProperty('access-control-expose-headers');
expect(rawHeaders).not.toHaveProperty('access-control-allow-credentials');
Expand Down
100 changes: 99 additions & 1 deletion __tests__/server/middleware/createRequestHtmlFragment.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,34 @@
import url from 'url';
import { browserHistory } from '@americanexpress/one-app-router';
import { Map as iMap, fromJS } from 'immutable';
import { composeModules } from 'holocron';
import match from '../../../src/universal/utils/matchPromisified';
// getBreaker is only added in the mock
/* eslint-disable-next-line import/named */
import { getBreaker } from '../../../src/server/utils/createCircuitBreaker';

import * as reactRendering from '../../../src/server/utils/reactRendering';

jest.mock('../../../src/universal/utils/matchPromisified');

jest.mock('holocron', () => ({
composeModules: jest.fn(() => 'composeModules'),
getModule: () => () => 0,
}));

jest.mock('../../../src/server/utils/createCircuitBreaker', () => {
const breaker = jest.fn();
const mockCreateCircuitBreaker = (asyncFuntionThatMightFail) => {
breaker.fire = jest.fn((...args) => {
asyncFuntionThatMightFail(...args);
return false;
});
return breaker;
};
mockCreateCircuitBreaker.getBreaker = () => breaker;
return mockCreateCircuitBreaker;
});

const renderForStringSpy = jest.spyOn(reactRendering, 'renderForString');
const renderForStaticMarkupSpy = jest.spyOn(reactRendering, 'renderForStaticMarkup');

Expand Down Expand Up @@ -59,6 +77,7 @@ describe('createRequestHtmlFragment', () => {
});

beforeEach(() => {
jest.clearAllMocks();
req = jest.fn();
req.headers = {};

Expand Down Expand Up @@ -87,20 +106,22 @@ describe('createRequestHtmlFragment', () => {
});

it('should preload data for matched route components', () => {
expect.assertions(4);
const createRequestHtmlFragment = require(
'../../../src/server/middleware/createRequestHtmlFragment'
).default;
const middleware = createRequestHtmlFragment({ createRoutes });
return middleware(req, res, next)
.then(() => {
const { composeModules } = require('holocron');
expect(getBreaker().fire).toHaveBeenCalled();
expect(composeModules).toHaveBeenCalled();
expect(composeModules.mock.calls[0][0]).toMatchSnapshot();
expect(dispatch).toHaveBeenCalledWith('composeModules');
});
});

it('should add app HTML to the request object', () => {
expect.assertions(5);
const createRequestHtmlFragment = require(
'../../../src/server/middleware/createRequestHtmlFragment'
).default;
Expand All @@ -116,6 +137,7 @@ describe('createRequestHtmlFragment', () => {
});

it('should add app HTML as static markup to the request object when scripts are disabled', () => {
expect.assertions(5);
const createRequestHtmlFragment = require(
'../../../src/server/middleware/createRequestHtmlFragment'
).default;
Expand All @@ -134,6 +156,7 @@ describe('createRequestHtmlFragment', () => {
});

it('should set the custom HTTP status', () => {
expect.assertions(4);
const createRequestHtmlFragment = require(
'../../../src/server/middleware/createRequestHtmlFragment'
).default;
Expand All @@ -150,6 +173,7 @@ describe('createRequestHtmlFragment', () => {
});

it('does not generate HTML when no route is matched', () => {
expect.assertions(5);
const createRequestHtmlFragment = require(
'../../../src/server/middleware/createRequestHtmlFragment'
).default;
Expand All @@ -174,6 +198,7 @@ describe('createRequestHtmlFragment', () => {
});

it('redirects when a relative redirect route is matched', () => {
expect.assertions(3);
const createRequestHtmlFragment = require(
'../../../src/server/middleware/createRequestHtmlFragment'
).default;
Expand All @@ -195,6 +220,7 @@ describe('createRequestHtmlFragment', () => {
});

it('redirects when an absolute redirect route is matched', () => {
expect.assertions(3);
const createRequestHtmlFragment = require(
'../../../src/server/middleware/createRequestHtmlFragment'
).default;
Expand All @@ -215,6 +241,7 @@ describe('createRequestHtmlFragment', () => {
});

it('should catch any errors and call the next middleware', () => {
expect.assertions(3);
const createRequestHtmlFragment = require(
'../../../src/server/middleware/createRequestHtmlFragment'
).default;
Expand All @@ -229,4 +256,75 @@ describe('createRequestHtmlFragment', () => {
expect(next).toHaveBeenCalled();
/* eslint-enable no-console */
});

it('should use a circuit breaker', async () => {
expect.assertions(6);
const createRequestHtmlFragment = require(
'../../../src/server/middleware/createRequestHtmlFragment'
).default;
const middleware = createRequestHtmlFragment({ createRoutes });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(getBreaker().fire).toHaveBeenCalled();
expect(composeModules).toHaveBeenCalled();
expect(renderForStringSpy).toHaveBeenCalled();
expect(renderForStaticMarkupSpy).not.toHaveBeenCalled();
expect(req.appHtml).toBe('hi');
});

it('should fall back when the circuit opens', async () => {
expect.assertions(5);
const createRequestHtmlFragment = require(
'../../../src/server/middleware/createRequestHtmlFragment'
).default;
const breaker = getBreaker();
breaker.fire.mockReturnValueOnce(true);
const middleware = createRequestHtmlFragment({ createRoutes });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(getBreaker().fire).toHaveBeenCalled();
expect(renderForStringSpy).not.toHaveBeenCalled();
expect(renderForStaticMarkupSpy).not.toHaveBeenCalled();
expect(req.appHtml).toBe('');
});

it('should not use the circuit breaker for partials', async () => {
expect.assertions(6);
const createRequestHtmlFragment = require(
'../../../src/server/middleware/createRequestHtmlFragment'
).default;
const middleware = createRequestHtmlFragment({ createRoutes });
getState.mockImplementationOnce(() => fromJS({
rendering: {
renderPartialOnly: true,
},
}));
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(getBreaker().fire).not.toHaveBeenCalled();
expect(composeModules).toHaveBeenCalled();
expect(renderForStringSpy).not.toHaveBeenCalled();
expect(renderForStaticMarkupSpy).toHaveBeenCalled();
expect(req.appHtml).toBe('hi');
});

it('should not use the circuit breaker when scripts are disabled', async () => {
expect.assertions(6);
const createRequestHtmlFragment = require(
'../../../src/server/middleware/createRequestHtmlFragment'
).default;
const middleware = createRequestHtmlFragment({ createRoutes });
getState.mockImplementationOnce(() => fromJS({
rendering: {
disableScripts: true,
},
}));
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(getBreaker().fire).not.toHaveBeenCalled();
expect(composeModules).toHaveBeenCalled();
expect(renderForStringSpy).not.toHaveBeenCalled();
expect(renderForStaticMarkupSpy).toHaveBeenCalled();
expect(req.appHtml).toBe('hi');
});
});
98 changes: 98 additions & 0 deletions __tests__/server/utils/createCircuitBreaker.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright 2020 American Express Travel Related Services Company, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

import CircuitBreaker from 'opossum';
import { getModule } from 'holocron';
import createCircuitBreaker, {
setEventLoopDelayThreshold,
getEventLoopDelayThreshold,
} from '../../../src/server/utils/createCircuitBreaker';

jest.useFakeTimers();

const asyncFuntionThatMightFail = jest.fn(async () => false);
const mockCircuitBreaker = createCircuitBreaker(asyncFuntionThatMightFail);

jest.mock('holocron', () => ({
getModule: jest.fn(() => true),
}));

describe('Circuit breaker', () => {
beforeEach(() => {
jest.clearAllMocks();
setEventLoopDelayThreshold();
mockCircuitBreaker.close();
});

it('should be an opossum circuit breaker', () => {
expect(mockCircuitBreaker).toBeInstanceOf(CircuitBreaker);
});

it('should call the given function', async () => {
expect.assertions(2);
const input = 'hello, world';
const value = await mockCircuitBreaker.fire(input);
expect(asyncFuntionThatMightFail).toHaveBeenCalledWith(input);
expect(value).toBe(false);
});

it('should open the circuit when event loop delay threshold is exceeded', async () => {
expect.assertions(2);
setEventLoopDelayThreshold(-1);
jest.advanceTimersByTime(510);
// Need to fire the breaker once before it will open
await mockCircuitBreaker.fire('hola, mundo');
jest.clearAllMocks();
const value = await mockCircuitBreaker.fire('hola, mundo');
expect(asyncFuntionThatMightFail).not.toHaveBeenCalled();
expect(value).toBe(true);
});

it('should not open the circuit when the root module is not loaded', async () => {
expect.assertions(2);
getModule.mockReturnValueOnce(false);
setEventLoopDelayThreshold(-1);
jest.advanceTimersByTime(510);
// Need to fire the breaker once before it will open
await mockCircuitBreaker.fire('hola, mundo');
jest.clearAllMocks();
const value = await mockCircuitBreaker.fire('hola, mundo');
expect(asyncFuntionThatMightFail).toHaveBeenCalled();
expect(value).toBe(false);
});

describe('event loop delay threshold', () => {
it('should set a default value of 30ms', () => {
setEventLoopDelayThreshold();
expect(getEventLoopDelayThreshold()).toBe(250);
});

it('should set value to 30ms if input is not a number', () => {
setEventLoopDelayThreshold('hello, world');
expect(getEventLoopDelayThreshold()).toBe(250);
});

it('should set the given value', () => {
setEventLoopDelayThreshold(44);
expect(getEventLoopDelayThreshold()).toBe(44);
});

it('should handle numbers as strings', () => {
setEventLoopDelayThreshold('55');
expect(getEventLoopDelayThreshold()).toBe(55);
});
});
});
19 changes: 18 additions & 1 deletion __tests__/server/utils/onModuleLoad.spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ import { setCorsOrigins } from '../../../src/server/middleware/conditionallyAllo
import { extendRestrictedAttributesAllowList, validateSafeRequestRestrictedAttributes } from '../../../src/server/utils/safeRequest';
import { setConfigureRequestLog } from '../../../src/server/utils/logging/serverMiddleware';
import { setCreateSsrFetch } from '../../../src/server/utils/createSsrFetch';
import { getEventLoopDelayThreshold } from '../../../src/server/utils/createCircuitBreaker';

jest.mock('../../../src/server/utils/stateConfig', () => ({
setStateConfig: jest.fn(),
getClientStateConfig: jest.fn(),
getServerStateConfig: jest.fn(),
getServerStateConfig: jest.fn(() => ({ rootModuleName: 'root-module' })),
}));
jest.mock('@americanexpress/env-config-utils');
jest.mock('../../../src/server/utils/readJsonFile', () => () => ({ buildVersion: '4.43.0-0-38f0178d' }));
Expand Down Expand Up @@ -311,6 +312,22 @@ describe('onModuleLoad', () => {
expect(setCorsOrigins).toHaveBeenCalledWith(corsOrigins);
});

it('sets the event loop lag threshold from the root module', () => {
const eventLoopDelayThreshold = 50;
expect(getEventLoopDelayThreshold()).not.toBe(eventLoopDelayThreshold);
onModuleLoad({
module: {
[CONFIGURATION_KEY]: {
csp,
eventLoopDelayThreshold,
},
[META_DATA_KEY]: { version: '1.0.14' },
},
moduleName: 'some-root',
});
expect(getEventLoopDelayThreshold()).toBe(eventLoopDelayThreshold);
});

it('logs when the root module is loaded', () => {
onModuleLoad({
module: { [CONFIGURATION_KEY]: { csp }, [META_DATA_KEY]: { version: '1.0.15' } },
Expand Down
20 changes: 20 additions & 0 deletions docs/api/modules/App-Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,26 @@ if (!global.BROWSER) {
}
```

## `eventLoopDelayThreshold`
**Module Type**
* ✅ Root Module
* 🚫 Child Module

**Shape**
```js
if (!global.BROWSER) {
Module.appConfig = {
eventLoopDelayThreshold: Number,
};
}
```

The `eventLoopDelayThreshold` directive accepts a number representing the threshold of the event loop delay (in milliseconds) before opening the circuit. Once the circuit is open, it will remain open for 10 seconds and close at that time pending the event loop delay. The default value is `250`. If you desire to disable the event loop delay potion of the circuit breaker, set this value to `Infinity`. The circuit will also open if the error rate exceeds 10%. In practice, `eventLoopDelayThreshold` allows for tuning server side rendering (SSR) of Modules. We may increase request throughput by temporarily disabling SSR at high load through event loop delay monitoring.

mtomcal marked this conversation as resolved.
Show resolved Hide resolved
**📘 More Information**
* [Frank Lloyd Root's `appConfig`](../../../prod-sample/sample-modules/frank-lloyd-root/0.0.0/src/config.js)
* Library: [Opossum](https://nodeshift.dev/opossum/)

`createSsrFetch` allows for customizing the fetch client used in `one-app` to perform server-side requests.

For example, you may wish to forward cookies or headers from the initial page load request to all the requisite SSR API requests.
Expand Down
23 changes: 23 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading