Challenge #9: Timeouts
The final challenge! This is a tough one. Your job is to handle timeouts in the ROT-13 service. To make it easier, implement the test and production code one assertion at a time.
Instructions
1. Implement the "fails gracefully, cancels request, and logs error, when service responds too slowly"
test:
- Configure the
Rot13Client
to hang when it’s called. - Assert that
HomePageController.postAsync()
returnshomePageView.homePage("ROT-13 service timed out")
. - Assert that it writes the following log message:
{ alert: "emergency", endpoint: "/", method: "POST", message: "ROT-13 service timed out", timeoutInMs: 5000, }
- Assert that it cancels the ROT-13 request.
2. Revise HomePageController.postAsync()
to do the following when the ROT-13 service doesn’t respond within five seconds:
- Cancel the service request.
- Log an emergency.
- Return the home page with
"ROT-13 service timed out"
in the text field.
Remember to commit your changes when you’re done.
API Documentation
const rot13Client = Rot13Client.createNull([{ hang }]);
If hang
is true
, creates a Nulled Rot13Client
that never returns the first time it’s called. Note that the parameter is an array of objects. To specify additional responses, add more objects to the array.
- hang
(boolean)
- if true, causes the ROT-13 client to hang; defaults to false - returns rot13Client
(Rot13Client)
- the ROT-13 client
await clock.advanceNulledClockUntilTimersExpireAsync()
Advance the current time until all timers expire. (Only works on Nulled Clock
instances.)
const result = await clock.timeoutAsync(timeoutInMs, promise, timeoutFnAsync)
Wait for promise
to resolve. If it doesn’t resolve before the time is up, run timeoutFnAsync()
and return its result instead.
Note that promise
continues to run after the time is up. It’s not possible to cancel promises in JavaScript. However, its resolution (return value) or rejection (exception) will be ignored.
- timeoutInMs
(number)
- the number of milliseconds to wait before runningtimeoutFnAsync()
- promise
(Promise<any>)
- the promise to wait for - timeoutFnAsync
(() => Promise<any>)
- the function to run if the time runs out - returns result
(any)
- the resolved value ofpromise
, if it resolved before the time ran out, or the result oftimeoutFnAsync()
if it didn’t
const { transformPromise, cancelFn } = rot13Client.transform(port, text, correlationId);
Call the ROT-13 service. The cancelFn()
return value can be used to cancel the request, which will cause transformPromise
to reject (throw an exception). It does nothing if the request is already complete.
If the request is cancelled, a cancellation object will be added to any active request trackers. This occurs after the request has been tracked, so a cancelled request will look like this:
[
{
port: 999,
text: "my text",
correlationId: "my-correlation-id",
},
{
port: 999,
text: "my text",
correlationId: "my-correlation-id",
cancelled: true,
}
]
- port
(number)
- the port of the ROT-13 service - text
(string)
- the text to send to the ROT-13 service - correlationId
(string)
- a unique ID representing the user’s request - returns transformPromise
(Promise<string>)
- the encoded text returned by the ROT-13 service - returns cancelFn
(() => void)
- call this function to cancel the request
const TIMEOUT_IN_MS = 5000;
This constant is available in the production code. Use it to time out the ROT-13 service.
JavaScript Primers
Hints
Update the test helper method:
1
You’ll need a post()
helper method that you can call without await
ing it.
To avoid breaking existing tests, duplicate postAsync()
as post()
.
Modify post()
to return a responsePromise
rather than a response
. (See the Using Promises primer.)
To do that, don’t await
the call to controller.postAsync()
. Be sure to remove the async
keyword from post()
as well.
async function postAsync({
body = `text=${IRRELEVANT_INPUT}`,
rot13ServicePort = IRRELEVANT_PORT,
correlationId = IRRELEVANT_CORRELATION_ID,
rot13Response = "irrelevant ROT-13 response",
rot13Error = undefined,
} = {}) {
const rot13Client = Rot13Client.createNull([{
response: rot13Response,
error: rot13Error,
}]);
const rot13Requests = rot13Client.trackRequests();
const log = Log.createNull();
const logOutput = log.trackOutput();
const clock = Clock.createNull();
const controller = new HomePageController(rot13Client, clock);
const request = HttpServerRequest.createNull({ body });
const config = WwwConfig.createTestInstance({ log, rot13ServicePort, correlationId });
const response = await controller.postAsync(request, config);
return { response, rot13Requests, logOutput };
}
function post({
body = `text=${IRRELEVANT_INPUT}`,
rot13ServicePort = IRRELEVANT_PORT,
correlationId = IRRELEVANT_CORRELATION_ID,
rot13Response = "irrelevant ROT-13 response",
rot13Error = undefined,
} = {}) {
const rot13Client = Rot13Client.createNull([{
response: rot13Response,
error: rot13Error,
}]);
const rot13Requests = rot13Client.trackRequests();
const log = Log.createNull();
const logOutput = log.trackOutput();
const clock = Clock.createNull();
const controller = new HomePageController(rot13Client, clock);
const request = HttpServerRequest.createNull({ body });
const config = WwwConfig.createTestInstance({ log, rot13ServicePort, correlationId });
const responsePromise = controller.postAsync(request, config);
return { responsePromise, rot13Requests, logOutput };
}
To simplify the TypeScript code, factor out an interface:
// TypeScript
interface PostOptions {
body?: string,
correlationId?: string,
rot13ServicePort?: number,
rot13Response?: string,
rot13Error?: string,
}
async function postAsync({
body = `text=${IRRELEVANT_INPUT}`,
rot13ServicePort = IRRELEVANT_PORT,
correlationId = IRRELEVANT_CORRELATION_ID,
rot13Response = "irrelevant ROT-13 response",
rot13Error = undefined,
}: PostOptions = {}) {
const rot13Client = Rot13Client.createNull([{
response: rot13Response,
error: rot13Error,
}]);
const rot13Requests = rot13Client.trackRequests();
const log = Log.createNull();
const logOutput = log.trackOutput();
const clock = Clock.createNull();
const controller = new HomePageController(rot13Client, clock);
const request = HttpServerRequest.createNull({ body });
const config = WwwConfig.createTestInstance({ log, rot13ServicePort, correlationId });
const response = await controller.postAsync(request, config);
return { response, rot13Requests, logOutput };
}
function post({
body = `text=${IRRELEVANT_INPUT}`,
rot13ServicePort = IRRELEVANT_PORT,
correlationId = IRRELEVANT_CORRELATION_ID,
rot13Response = "irrelevant ROT-13 response",
rot13Error = undefined,
}: PostOptions = {}) {
const rot13Client = Rot13Client.createNull([{
response: rot13Response,
error: rot13Error,
}]);
const rot13Requests = rot13Client.trackRequests();
const log = Log.createNull();
const logOutput = log.trackOutput();
const clock = Clock.createNull();
const controller = new HomePageController(rot13Client, clock);
const request = HttpServerRequest.createNull({ body });
const config = WwwConfig.createTestInstance({ log, rot13ServicePort, correlationId });
const responsePromise = controller.postAsync(request, config);
return { responsePromise, rot13Requests, logOutput };
}
2
Refactor to eliminate the duplication between post()
and postAsync()
.
postAsync()
needs to call post()
and await the responsePromise
, while returning the other values unchanged.
The rest and spread operators make this easier.
async function postAsync(options) { // use "options: PostOptions" in TypeScript
const { responsePromise, ...remainder } = post(options);
return {
response: await responsePromise,
...remainder,
};
}
function post({
body = `text=${IRRELEVANT_INPUT}`,
rot13ServicePort = IRRELEVANT_PORT,
correlationId = IRRELEVANT_CORRELATION_ID,
rot13Response = "irrelevant ROT-13 response",
rot13Error = undefined,
} = {}) { // use "}: PostOptions = {}" in TypeScript
const rot13Client = Rot13Client.createNull([{
response: rot13Response,
error: rot13Error,
}]);
const rot13Requests = rot13Client.trackRequests();
const log = Log.createNull();
const logOutput = log.trackOutput();
const clock = Clock.createNull();
const controller = new HomePageController(rot13Client, clock);
const request = HttpServerRequest.createNull({ body });
const config = WwwConfig.createTestInstance({ log, rot13ServicePort, correlationId });
const responsePromise = controller.postAsync(request, config);
return { responsePromise, rot13Requests, logOutput };
}
3
You’ll need the ability to control the clock.
Return the Clock
instance from the post()
helper.
function post({
body = `text=${IRRELEVANT_INPUT}`,
rot13ServicePort = IRRELEVANT_PORT,
correlationId = IRRELEVANT_CORRELATION_ID,
rot13Response = "irrelevant ROT-13 response",
rot13Error = undefined,
} = {}) {
const rot13Client = Rot13Client.createNull([{
response: rot13Response,
error: rot13Error,
}]);
const rot13Requests = rot13Client.trackRequests();
const log = Log.createNull();
const logOutput = log.trackOutput();
const clock = Clock.createNull();
const controller = new HomePageController(rot13Client, clock);
const request = HttpServerRequest.createNull({ body });
const config = WwwConfig.createTestInstance({ log, rot13ServicePort, correlationId });
const responsePromise = controller.postAsync(request, config);
return {
responsePromise,
rot13Requests,
logOutput,
clock,
};
}
4
You’ll need to modify your the post()
helper to support making the ROT-13 client hang.
Add an optional rot13Hang
parameter to the signature and default it to false
.
You can make Rot13Client
hang by passing it a [{ hang }]
parameter.
function post({
body = `text=${IRRELEVANT_INPUT}`,
rot13ServicePort = IRRELEVANT_PORT,
correlationId = IRRELEVANT_CORRELATION_ID,
rot13Response = "irrelevant ROT-13 response",
rot13Error = undefined,
rot13Hang = false,
} = {}) {
const rot13Client = Rot13Client.createNull([{
response: rot13Response,
error: rot13Error,
hang: rot13Hang,
}]);
const rot13Requests = rot13Client.trackRequests();
const log = Log.createNull();
const logOutput = log.trackOutput();
const clock = Clock.createNull();
const controller = new HomePageController(rot13Client, clock);
const request = HttpServerRequest.createNull({ body });
const config = WwwConfig.createTestInstance({ log, rot13ServicePort, correlationId });
const responsePromise = controller.postAsync(request, config);
return {
responsePromise,
rot13Requests,
logOutput,
clock,
};
}
In TypeScript, add the new parameter to the PostOptions
interface:
// TypeScript
interface PostOptions {
body?: string,
correlationId?: string,
rot13ServicePort?: number,
rot13Response?: string,
rot13Error?: string,
rot13Hang?: boolean,
}
Return "ROT-13 service timed out"
:
5
You’re ready to create the test.
Your production code will set a timeout, so your test needs to advance the clock until timers expire.
You can do that with clock.advanceNulledClockUntilTimersExpireAsync()
.
it("fails gracefully, cancels request, and logs error, when service responds too slowly", async () => {
const { responsePromise, clock } = await post({ rot13Hang: true });
await clock.advanceNulledClockUntilTimersExpireAsync();
const response = await responsePromise;
assert.deepEqual(response, homePageView.homePage("ROT-13 service timed out"), "should render home page");
});
6
The test is ready to run.
It should fail with a timeout.
That’s because the ROT-13 service is configured to hang and the production code isn’t timing it out.
7
Modify your production code to return "ROT-13 service timed out"
when the ROT-13 service doesn’t respond.
You can use clock.timeoutAsync()
to do that. It takes an arrow function as a parameter.
Put the timeout code after the call to rot13Client.transform()
.
export class HomePageController {
// ...
async postAsync(request, config) {
const log = config.log.bind({
endpoint: ENDPOINT,
method: "POST",
});
const userInput = await parseRequestBodyAsync(request, log);
if (userInput === null) return homePageView.homePage();
const output = await transformAsync(this._rot13Client, config, log, this._clock, userInput);
if (output === null) return homePageView.homePage("ROT-13 service failed");
return homePageView.homePage(output);
}
}
async function parseRequestBodyAsync(request, log) {
// ...
}
async function transformAsync(rot13Client, config, log, clock, userInput) {
try {
const { transformPromise } = rot13Client.transform(
config.rot13ServicePort,
userInput,
config.correlationId,
);
return await clock.timeoutAsync(
TIMEOUT_IN_MS,
transformPromise,
() => {
return "ROT-13 service timed out";
},
);
}
catch (err) {
log.emergency({
message: "ROT-13 service error",
error: err,
});
return null;
}
}
In TypeScript, you’ll need to declare the type of the clock
parameter:
// TypeScript
async function transformAsync(
rot13Client: Rot13Client,
config: WwwConfig,
log: Log,
clock: Clock,
userInput: string,
): Promise<string | null> {
try {
const { transformPromise } = rot13Client.transform(
config.rot13ServicePort,
userInput,
config.correlationId,
);
return await clock.timeoutAsync(
TIMEOUT_IN_MS,
transformPromise,
() => {
return "ROT-13 service timed out";
},
);
}
catch (err) {
log.emergency({
message: "ROT-13 service error",
error: err,
});
return null;
}
}
Write to the log:
8
Update your test to check the log output.
This is the same as other tests.
it("fails gracefully, cancels request, and logs error, when service responds too slowly", async () => {
const { responsePromise, clock, logOutput } = await post({ rot13Hang: true });
await clock.advanceNulledClockUntilTimersExpireAsync();
const response = await responsePromise;
assert.deepEqual(logOutput.data, [{
alert: "emergency",
endpoint: "/",
method: "POST",
message: "ROT-13 service timed out",
timeoutInMs: 5000,
}], "should log an emergency");
assert.deepEqual(response, homePageView.homePage("ROT-13 service timed out"), "should render home page");
});
9
The test is ready to run again.
It should fail, saying that no log was written.
This is because the production code isn’t writing to the log when it times out.
10
Make the production code write to the log when it times out.
Add the logging to the arrow function you’re passing into clock.timeoutAsync()
.
async function transformAsync(rot13Client, config, log, clock, userInput) {
try {
const { transformPromise } = rot13Client.transform(
config.rot13ServicePort,
userInput,
config.correlationId,
);
return await clock.timeoutAsync(
TIMEOUT_IN_MS,
transformPromise,
() => {
log.emergency({
message: "ROT-13 service timed out",
timeoutInMs: TIMEOUT_IN_MS,
});
return "ROT-13 service timed out";
},
);
}
catch (err) {
log.emergency({
message: "ROT-13 service error",
error: err,
});
return null;
}
}
11
The timeout code is getting a bit messy. Take a moment to clean it up.
Factor the timeout’s arrow function into a function named timeout(log)
.
async function transformAsync(rot13Client, config, log, clock, userInput) {
try {
const { transformPromise } = rot13Client.transform(
config.rot13ServicePort,
userInput,
config.correlationId,
);
return await clock.timeoutAsync(
TIMEOUT_IN_MS,
transformPromise,
() => timeout(log),
);
}
catch (err) {
log.emergency({
message: "ROT-13 service error",
error: err,
});
return null;
}
}
function timeout(log) {
log.emergency({
message: "ROT-13 service timed out",
timeoutInMs: TIMEOUT_IN_MS,
});
return "ROT-13 service timed out";
}
In TypeScript, you’ll need to declare the types of the timeout()
function:
// TypeScript
function timeout(log: Log): string {
log.emergency({
message: "ROT-13 service timed out",
timeoutInMs: TIMEOUT_IN_MS,
});
return "ROT-13 service timed out";
}
Cancel the ROT-13 service request:
12
Assert that the request is cancelled.
You can use rot13Requests.data
to see if a request has been cancelled.
You can use the IRRELEVANT_XXX
constants to ignore the port, correlation ID, and so forth.
it("fails gracefully, cancels request, and logs error, when service responds too slowly", async () => {
const { responsePromise, clock, logOutput, rot13Requests } = await post({ rot13Hang: true });
await clock.advanceNulledClockUntilTimersExpireAsync();
const response = await responsePromise;
assert.deepEqual(logOutput.data, [{
alert: "emergency",
endpoint: "/",
method: "POST",
message: "ROT-13 service timed out",
timeoutInMs: 5000,
}], "should log an emergency");
assert.deepEqual(rot13Requests.data, [
{
port: IRRELEVANT_PORT,
text: IRRELEVANT_INPUT,
correlationId: IRRELEVANT_CORRELATION_ID,
},
{
port: IRRELEVANT_PORT,
text: IRRELEVANT_INPUT,
correlationId: IRRELEVANT_CORRELATION_ID,
cancelled: true,
},
], "should cancel request");
assert.deepEqual(response, homePageView.homePage("ROT-13 service timed out"), "should render home page");
});
13
The test is ready to run again.
It should fail, saying that rot13Requests.data
doesn’t include the cancellation.
That’s because the production code isn’t cancelling the request.
14
Make the production code cancel the request.
You can do that with the cancelFn()
returned by rot13Client.transform()
.
async function transformAsync(rot13Client, config, log, clock, userInput) {
try {
const { transformPromise, cancelFn } = rot13Client.transform(
config.rot13ServicePort,
userInput,
config.correlationId,
);
return await clock.timeoutAsync(
TIMEOUT_IN_MS,
transformPromise,
() => timeout(log, cancelFn),
);
}
catch (err) {
log.emergency({
message: "ROT-13 service error",
error: err,
});
return null;
}
}
function timeout(log, cancelFn) {
log.emergency({
message: "ROT-13 service timed out",
timeoutInMs: TIMEOUT_IN_MS,
});
cancelFn();
return "ROT-13 service timed out";
}
In TypeScript, you’ll need to declare the type of the cancelFn
parameter:
// TypeScript
function timeout(log: Log, cancelFn: () => void): string {
log.emergency({
message: "ROT-13 service timed out",
timeoutInMs: TIMEOUT_IN_MS,
});
cancelFn();
return "ROT-13 service timed out";
}
Complete Solution
Test code (JavaScript):
describe.only("Home Page Controller", () => {
describe("happy paths", () => {
it("GET renders home page", async () => {
const { response } = await getAsync();
assert.deepEqual(response, homePageView.homePage());
});
it("POST asks ROT-13 service to transform text", async () => {
const { rot13Requests } = await postAsync({
body: "text=hello%20world",
rot13ServicePort: 999,
correlationId: "my-correlation-id",
});
assert.deepEqual(rot13Requests.data, [{
text: "hello world",
port: 999,
correlationId: "my-correlation-id",
}]);
});
it("POST renders result of ROT-13 service call", async () => {
const { response } = await postAsync({ rot13Response: "my_response" });
assert.deepEqual(response, homePageView.homePage("my_response"));
});
});
describe("parse edge cases", () => {
it("logs warning when form field not found (and treats request like GET)", async () => {
const { response, rot13Requests, logOutput } = await postAsync({ body: "" });
assert.deepEqual(logOutput.data, [{
alert: "monitor",
endpoint: "/",
method: "POST",
message: "form parse error",
error: "'text' form field not found",
form: {},
}], "should log a warning");
assert.deepEqual(response, homePageView.homePage(), "should render home page");
assert.deepEqual(rot13Requests.data, [], "shouldn't call ROT-13 service");
});
it("logs warning when duplicated form field found (and treats request like GET)", async () => {
const { response, rot13Requests, logOutput } = await postAsync({ body: "text=one&text=two" });
assert.deepEqual(logOutput.data, [{
alert: "monitor",
endpoint: "/",
method: "POST",
message: "form parse error",
error: "should only be one 'text' form field",
form: {
text: [ "one", "two" ],
},
}], "should log a warning");
assert.deepEqual(response, homePageView.homePage(), "should render home page");
assert.deepEqual(rot13Requests.data, [], "shouldn't call ROT-13 service");
});
});
describe("ROT-13 service edge cases", () => {
it("fails gracefully, and logs error, when service returns error", async () => {
const { response, logOutput } = await postAsync({
rot13ServicePort: 9999,
rot13Error: "my_error"
});
assert.deepEqual(logOutput.data, [{
alert: "emergency",
endpoint: "/",
method: "POST",
message: "ROT-13 service error",
error: "Error: Unexpected status from ROT-13 service\n" +
"Host: localhost:9999\n" +
"Endpoint: /rot13/transform\n" +
"Status: 500\n" +
"Headers: {}\n" +
"Body: my_error",
}], "should log an emergency");
assert.deepEqual(response, homePageView.homePage("ROT-13 service failed"), "should render home page");
});
it("fails gracefully, cancels request, and logs error, when service responds too slowly", async () => {
const { responsePromise, clock, logOutput, rot13Requests } = await post({ rot13Hang: true });
await clock.advanceNulledClockUntilTimersExpireAsync();
const response = await responsePromise;
assert.deepEqual(logOutput.data, [{
alert: "emergency",
endpoint: "/",
method: "POST",
message: "ROT-13 service timed out",
timeoutInMs: 5000,
}], "should log an emergency");
assert.deepEqual(rot13Requests.data, [
{
port: IRRELEVANT_PORT,
text: IRRELEVANT_INPUT,
correlationId: IRRELEVANT_CORRELATION_ID,
},
{
port: IRRELEVANT_PORT,
text: IRRELEVANT_INPUT,
correlationId: IRRELEVANT_CORRELATION_ID,
cancelled: true,
},
], "should cancel request");
assert.deepEqual(response, homePageView.homePage("ROT-13 service timed out"), "should render home page");
});
});
});
async function getAsync() {
const rot13Client = Rot13Client.createNull();
const clock = Clock.createNull();
const controller = new HomePageController(rot13Client, clock);
const request = HttpServerRequest.createNull();
const config = WwwConfig.createTestInstance();
const response = await controller.getAsync(request, config);
return { response };
}
async function postAsync(options) {
const { responsePromise, ...remainder } = post(options);
return {
response: await responsePromise,
...remainder,
};
}
function post({
body = `text=${IRRELEVANT_INPUT}`,
rot13ServicePort = IRRELEVANT_PORT,
correlationId = IRRELEVANT_CORRELATION_ID,
rot13Response = "irrelevant ROT-13 response",
rot13Error = undefined,
rot13Hang = false,
} = {}) {
const rot13Client = Rot13Client.createNull([{
response: rot13Response,
error: rot13Error,
hang: rot13Hang,
}]);
const rot13Requests = rot13Client.trackRequests();
const log = Log.createNull();
const logOutput = log.trackOutput();
const clock = Clock.createNull();
const controller = new HomePageController(rot13Client, clock);
const request = HttpServerRequest.createNull({ body });
const config = WwwConfig.createTestInstance({ log, rot13ServicePort, correlationId });
const responsePromise = controller.postAsync(request, config);
return {
responsePromise,
rot13Requests,
logOutput,
clock,
};
}
Test code (TypeScript):
describe.only("Home Page Controller", () => {
describe("happy paths", () => {
it("GET renders home page", async () => {
const { response } = await getAsync();
assert.deepEqual(response, homePageView.homePage());
});
it("POST asks ROT-13 service to transform text", async () => {
const { rot13Requests } = await postAsync({
body: "text=hello%20world",
rot13ServicePort: 999,
correlationId: "my-correlation-id",
});
assert.deepEqual(rot13Requests.data, [{
text: "hello world",
port: 999,
correlationId: "my-correlation-id",
}]);
});
it("POST renders result of ROT-13 service call", async () => {
const { response } = await postAsync({ rot13Response: "my_response" });
assert.deepEqual(response, homePageView.homePage("my_response"));
});
});
describe("parse edge cases", () => {
it("logs warning when form field not found (and treats request like GET)", async () => {
const { response, rot13Requests, logOutput } = await postAsync({ body: "" });
assert.deepEqual(logOutput.data, [{
alert: "monitor",
endpoint: "/",
method: "POST",
message: "form parse error",
error: "'text' form field not found",
form: {},
}], "should log a warning");
assert.deepEqual(response, homePageView.homePage(), "should render home page");
assert.deepEqual(rot13Requests.data, [], "shouldn't call ROT-13 service");
});
it("logs warning when duplicated form field found (and treats request like GET)", async () => {
const { response, rot13Requests, logOutput } = await postAsync({ body: "text=one&text=two" });
assert.deepEqual(logOutput.data, [{
alert: "monitor",
endpoint: "/",
method: "POST",
message: "form parse error",
error: "should only be one 'text' form field",
form: {
text: [ "one", "two" ],
},
}], "should log a warning");
assert.deepEqual(response, homePageView.homePage(), "should render home page");
assert.deepEqual(rot13Requests.data, [], "shouldn't call ROT-13 service");
});
});
describe("ROT-13 service edge cases", () => {
it("fails gracefully, and logs error, when service returns error", async () => {
const { response, logOutput } = await postAsync({
rot13ServicePort: 9999,
rot13Error: "my_error"
});
assert.deepEqual(logOutput.data, [{
alert: "emergency",
endpoint: "/",
method: "POST",
message: "ROT-13 service error",
error: "Error: Unexpected status from ROT-13 service\n" +
"Host: localhost:9999\n" +
"Endpoint: /rot13/transform\n" +
"Status: 500\n" +
"Headers: {}\n" +
"Body: my_error",
}], "should log an emergency");
assert.deepEqual(response, homePageView.homePage("ROT-13 service failed"), "should render home page");
});
it("fails gracefully, cancels request, and logs error, when service responds too slowly", async () => {
const { responsePromise, clock, logOutput, rot13Requests } = await post({ rot13Hang: true });
await clock.advanceNulledClockUntilTimersExpireAsync();
const response = await responsePromise;
assert.deepEqual(logOutput.data, [{
alert: "emergency",
endpoint: "/",
method: "POST",
message: "ROT-13 service timed out",
timeoutInMs: 5000,
}], "should log an emergency");
assert.deepEqual(rot13Requests.data, [
{
port: IRRELEVANT_PORT,
text: IRRELEVANT_INPUT,
correlationId: IRRELEVANT_CORRELATION_ID,
},
{
port: IRRELEVANT_PORT,
text: IRRELEVANT_INPUT,
correlationId: IRRELEVANT_CORRELATION_ID,
cancelled: true,
},
], "should cancel request");
assert.deepEqual(response, homePageView.homePage("ROT-13 service timed out"), "should render home page");
});
});
});
async function getAsync() {
const rot13Client = Rot13Client.createNull();
const clock = Clock.createNull();
const controller = new HomePageController(rot13Client, clock);
const request = HttpServerRequest.createNull();
const config = WwwConfig.createTestInstance();
const response = await controller.getAsync(request, config);
return { response };
}
interface PostOptions {
body?: string,
correlationId?: string,
rot13ServicePort?: number,
rot13Response?: string,
rot13Error?: string,
rot13Hang?: boolean,
}
async function postAsync(options: PostOptions) {
const { responsePromise, ...remainder } = post(options);
return {
response: await responsePromise,
...remainder,
};
}
function post({
body = `text=${IRRELEVANT_INPUT}`,
rot13ServicePort = IRRELEVANT_PORT,
correlationId = IRRELEVANT_CORRELATION_ID,
rot13Response = "irrelevant ROT-13 response",
rot13Error = undefined,
rot13Hang = false,
}: PostOptions = {}) {
const rot13Client = Rot13Client.createNull([{
response: rot13Response,
error: rot13Error,
hang: rot13Hang,
}]);
const rot13Requests = rot13Client.trackRequests();
const log = Log.createNull();
const logOutput = log.trackOutput();
const clock = Clock.createNull();
const controller = new HomePageController(rot13Client, clock);
const request = HttpServerRequest.createNull({ body });
const config = WwwConfig.createTestInstance({ log, rot13ServicePort, correlationId });
const responsePromise = controller.postAsync(request, config);
return {
responsePromise,
rot13Requests,
logOutput,
clock,
};
}
Production code (JavaScript):
export class HomePageController {
// ...
async getAsync(request, config) {
return homePageView.homePage();
}
async postAsync(request, config) {
const log = config.log.bind({
endpoint: ENDPOINT,
method: "POST",
});
const userInput = await parseRequestBodyAsync(request, log);
if (userInput === null) return homePageView.homePage();
const output = await transformAsync(this._rot13Client, config, log, this._clock, userInput);
if (output === null) return homePageView.homePage("ROT-13 service failed");
return homePageView.homePage(output);
}
}
async function parseRequestBodyAsync(request, log) {
const form = await request.readBodyAsUrlEncodedFormAsync();
const textFields = form[INPUT_FIELD_NAME];
try {
if (textFields === undefined) throw new Error(`'${INPUT_FIELD_NAME}' form field not found`);
if (textFields.length !== 1) throw new Error(`should only be one '${INPUT_FIELD_NAME}' form field`);
return textFields[0];
}
catch (err) {
log.monitor({
message: "form parse error",
error: err.message,
form,
});
return null;
}
}
async function transformAsync(rot13Client, config, log, clock, userInput) {
try {
const { transformPromise, cancelFn } = rot13Client.transform(
config.rot13ServicePort,
userInput,
config.correlationId,
);
return await clock.timeoutAsync(
TIMEOUT_IN_MS,
transformPromise,
() => timeout(log, cancelFn),
);
}
catch (err) {
log.emergency({
message: "ROT-13 service error",
error: err,
});
return null;
}
}
function timeout(log, cancelFn) {
log.emergency({
message: "ROT-13 service timed out",
timeoutInMs: TIMEOUT_IN_MS,
});
cancelFn();
return "ROT-13 service timed out";
}
Production code (TypeScript):
export class HomePageController {
//...
async getAsync(request: HttpServerRequest, config: WwwConfig): Promise<HttpServerResponse> {
return homePageView.homePage();
}
async postAsync(request: HttpServerRequest, config: WwwConfig): Promise<HttpServerResponse> {
const log = config.log.bind({
endpoint: ENDPOINT,
method: "POST",
});
const userInput = await parseRequestBodyAsync(request, log);
if (userInput === null) return homePageView.homePage();
const output = await transformAsync(this._rot13Client, config, log, this._clock, userInput);
if (output === null) return homePageView.homePage("ROT-13 service failed");
return homePageView.homePage(output);
}
}
async function parseRequestBodyAsync(request: HttpServerRequest, log: Log): Promise<string | null> {
const form = await request.readBodyAsUrlEncodedFormAsync();
const textFields = form[INPUT_FIELD_NAME];
try {
if (textFields === undefined) throw new Error(`'${INPUT_FIELD_NAME}' form field not found`);
if (textFields.length !== 1) throw new Error(`should only be one '${INPUT_FIELD_NAME}' form field`);
return textFields[0] as string;
}
catch (err) {
log.monitor({
message: "form parse error",
error: err.message,
form,
});
return null;
}
}
async function transformAsync(
rot13Client: Rot13Client,
config: WwwConfig,
log: Log,
clock: Clock,
userInput: string,
): Promise<string | null> {
try {
const { transformPromise, cancelFn } = rot13Client.transform(
config.rot13ServicePort,
userInput,
config.correlationId,
);
return await clock.timeoutAsync(
TIMEOUT_IN_MS,
transformPromise,
() => timeout(log, cancelFn),
);
}
catch (err) {
log.emergency({
message: "ROT-13 service error",
error: err,
});
return null;
}
}
function timeout(log: Log, cancelFn: () => void): string {
log.emergency({
message: "ROT-13 service timed out",
timeoutInMs: TIMEOUT_IN_MS,
});
cancelFn();
return "ROT-13 service timed out";
}