Challenge #7: Service Errors

In this challenge, you’ll make sure your code handles errors in the ROT-13 service gracefully.

Instructions

1. Implement the "fails gracefully, and logs error, when service returns error" test:

  1. Configure the Rot13Client to throw an error that includes the text "my_error".
  2. Assert that HomePageController.postAsync() returns homePageView.homePage("ROT-13 service failed").
  3. Assert that it writes the following log message:
    {
    	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",
    }

2. Revise HomePageController.postAsync() to do the following when the ROT-13 service throws an error:

  1. Log an emergency.
  2. Return the home page with "ROT-13 service failed" in the text field.

Remember to commit your changes when you’re done.

API Documentation

const rot13Client = Rot13Client.createNull([{ error }]);

Creates a Nulled Rot13Client that throws an error the first time it’s called. (The client returns normally if error is undefined.) Note that the parameter is an array of objects. To specify additional responses, add more objects to the array.

  • error (string) - the body of the ROT-13 service response that causes the error
  • returns rot13Client (Rot13Client) - the ROT-13 client
log.emergency(data);

Write data to the log with the emergency alert level.

  • data (object) - an object containing anything you want
const boundLog = log.bind(data);

Create a new logger with predefined values. For example:

const boundLog = log.bind({ myVar: "foo" });
boundLog.emergency({ message: "my message" });

writes the following object to the log:

{
	alert: "emergency",
	myVar: "foo",
	message: "my message",
}

Output tracking is shared between both loggers.

  • data (object) - an object containing anything you want
  • returns boundLog (Log) - a new logger that automatically includes data

JavaScript Primers

No new concepts.

Hints

1
You’ll need to modify your tests’ postAsync() helper to support throwing an error.
Add an optional rot13Error parameter to the signature and default it to undefined.
You can make Rot13Client throw an error by passing it an [{ error }] parameter.
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 };
}

Because the rot13Error parameter doesn’t have a default value, TypeScript requires postAsync()’s types to be explicitly defined:

// TypeScript
async function postAsync({
	body = `text=${IRRELEVANT_INPUT}`,
	rot13ServicePort = IRRELEVANT_PORT,
	correlationId = IRRELEVANT_CORRELATION_ID,
	rot13Response = "irrelevant ROT-13 response",
	rot13Error = undefined,
}: {
	body?: string,
	correlationId?: string,
	rot13ServicePort?: number,
	rot13Response?: string,
	rot13Error?: string,
} = {}) {
	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 };
}
2
You’re ready to write the test. To start with, just assert that it returns the correct page.
It’s similar to the previous edge-case tests.
it("fails gracefully, and logs error, when service returns error", async () => {
	const { response } = await postAsync({ rot13Error: "my_error" });
	assert.deepEqual(response, homePageView.homePage("ROT-13 service failed"), "should render home page");
});
3
The test is ready to run.
The test should fail with an "Unexpected status from ROT-13 service" error.

This is happening because the Rot13Client is throwing an error, as it was asked to do, and nothing is catching the error.

4
Modify the production code to handle the Rot13Client error.
Surround the rot13Client.transformAsync() call with a try/catch block. You’ll need to move the output declaration outside the try block.
Use homePageView.homePage() to return the home page.
async postAsync(request, config) {
	const userInput = await parseRequestBodyAsync(request, config.log);
	if (userInput === null) return homePageView.homePage();

	let output;
	try {
		output = await this._rot13Client.transformAsync(
			config.rot13ServicePort,
			userInput,
			config.correlationId,
		);
	}
	catch (err) {
		return homePageView.homePage("ROT-13 service failed");
	}

	return homePageView.homePage(output);
}
5
Before testing the logging, refactor the production code to be cleaner.
You can use the same strategy as for the parser.
First, move the early return out of the try/catch block.
Next, pull this._rot13Client into a variable above the code you plan to extract.
Finally, extract the code into a function named transformAsync—don’t forget to await it—and clean up.
export class HomePageController {
	//...

	async postAsync(request, config) {
		const userInput = await parseRequestBodyAsync(request, config.log);
		if (userInput === null) return homePageView.homePage();

		const output = await transformAsync(this._rot13Client, config, 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, userInput) {
	try {
		return await rot13Client.transformAsync(
			config.rot13ServicePort,
			userInput,
			config.correlationId,
		);
	}
	catch (err) {
		return null;
	}
}

TypeScript requires transformAsync()’s types to be specified:

// TypeScript
async function transformAsync(
	rot13Client: Rot13Client,
	config: WwwConfig,
	log: Log,
	userInput: string,
): Promise<string | null> {
	try {
		return await rot13Client.transformAsync(
			config.rot13ServicePort,
			userInput,
			config.correlationId,
		);
	}
	catch (err) {
		return null;
	}
}
6
You’re ready to test the logging.
You can use logOutput.data to see what’s written to the log.
it("fails gracefully, and logs error, when service returns error", async () => {
	const { response, logOutput } = await postAsync({ 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");
});
7
It’s time to run the test.
The test should fail, saying that nothing was logged.

That’s because your exception handler isn’t logging anything.

8
Add logging to your exception handler.
You can log an emergency with config.log.emergency().
async function transformAsync(rot13Client, config, userInput) {
	try {
		return await rot13Client.transformAsync(
			config.rot13ServicePort,
			userInput,
			config.correlationId,
		);
	}
	catch (err) {
		config.log.emergency({
			endpoint: ENDPOINT,
			method: "POST",
			message: "ROT-13 service error",
			error: err,
		});
		return null;
	}
}

The test will still fail after you add this logging, but it will fail in a different way.

9
The test is still failing.
The error message should show that it’s expecting localhost:9999 in the logs, but it’s getting localhost:42.
42 is the default ROT-13 service port set by WwwConfig.createTestInstance().

The logging is showing localhost:42 because the test isn’t setting the ROT-13 service port.

10
It’s tempting to make the test pass by changing the assertion, but you shouldn’t rely on default values in your assertions. They could change in the future.
Instead, explicitly specify the ROT-13 service port.
You can do that by passing the rot13ServicePort parameter to your postAsync() helper.
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");
});
11
There’s still some duplication in the production code’s logging.
You can eliminate it with log.bind().
Bind the log in postAsync() and pass the bound log into parseRequestBodyAsync() and transformAsync().
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, 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, userInput) {
	try {
		return await rot13Client.transformAsync(
			config.rot13ServicePort,
			userInput,
			config.correlationId,
		);
	}
	catch (err) {
		log.emergency({
			message: "ROT-13 service error",
			error: err,
		});
		return null;
	}
}

TypeScript requires the type of the log parameter to be specified:

// TypeScript
async function transformAsync(
	rot13Client: Rot13Client,
	config: WwwConfig,
	log: Log,
	userInput: string,
): Promise<string | null> {
	try {
		return await rot13Client.transformAsync(
			config.rot13ServicePort,
			userInput,
			config.correlationId,
		);
	}
	catch (err) {
		log.emergency({
			message: "ROT-13 service error",
			error: err,
		});
		return null;
	}
}

Complete Solution

Test code (JavaScript):
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");
});

// ...

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 };
}
Test code (TypeScript):
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");
});

// ...

async function postAsync({
	body = `text=${IRRELEVANT_INPUT}`,
	rot13ServicePort = IRRELEVANT_PORT,
	correlationId = IRRELEVANT_CORRELATION_ID,
	rot13Response = "irrelevant ROT-13 response",
	rot13Error,
}: {
	body?: string,
	correlationId?: string,
	rot13ServicePort?: number,
	rot13Response?: string,
	rot13Error?: string,
} = {}) {
	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 };
}
Production code (JavaScript):
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, 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, userInput) {
	try {
		return await rot13Client.transformAsync(
			config.rot13ServicePort,
			userInput,
			config.correlationId,
		);
	}
	catch (err) {
		log.emergency({
			message: "ROT-13 service error",
			error: err,
		});
		return null;
	}
}
Production code (TypeScript):
export class HomePageController {
	// ...

	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, 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,
	userInput: string,
): Promise<string | null> {
	try {
		return await rot13Client.transformAsync(
			config.rot13ServicePort,
			userInput,
			config.correlationId,
		);
	}
	catch (err) {
		log.emergency({
			message: "ROT-13 service error",
			error: err,
		});
		return null;
	}
}

Next challenge

Return to module overview