Chapter 10. Error Handling

What error drives our eyes and ears amiss?

William Shakespeare (through the tongue of Antipholus of Syracuse), The Comedy of Errors

Mistakes are a part of life. One of the reasons for adopting test-driven development is to ensure that we can go as fast as we safely can, minimizing bugs in code.

The next item on our feature list is to improve error handling.

5 USD x 2 = 10 USD

10 EUR x 2 = 20 EUR

4002 KRW / 4 = 1000.5 KRW

5 USD + 10 USD = 15 USD

Separate test code from production code

Remove redundant tests

5 USD + 10 EUR = 17 USD

1 USD + 1100 KRW = 2200 KRW#

Determine exchange rate based on the currencies involved (from → to)

Improve error handling when exchange rates are unspecified

Allow exchange rates to be modified

Error wish list

The way our code currently handles missing exchange rates is buggy. Let’s address this shortcoming. Table 10-1 shows our wish list for handling errors due to missing exchange rates.


Table 10-1. Wish list for handling errors due to missing exchange rates
Item Description

1

The evaluate method should signal an explicit error when one or more necessary exchange rates are missing.

2

The error message should be “greedy" — that is, it should indicate all the missing exchange rates that prevent a Portfolio from being evaluated, not just the first missing exchange rate.

3

To prevent the error from being ignored by the caller, no valid Money should be returned when an error happens due to missing exchange rates.


For instance, if we try to evaluate a portfolio in the currency “Kalganid”, 1 for which there are no defined exchange rates, we should get a detailed error message listing all the missing exchange rates.

Go

We’ll need to change the signature of our convert and Evaluate methods when there is a missing exchange rate. We are currently returning only one return value from these methods. To indicate an error — the inability to find an exchange rate — we need a second return value.

Tip

In Go, the idiomatic way to indicate failure is to return an error as the last return value from a function or method so the caller can check for it.

Here is the pseudocode for how Evaluate and convert should collaboratively work using Go’s idioms:

Evaluate:
    For each Money struct:
        Try to convert Money to target currency and add it to the total amount
            If convert returns an error:
                Capture the "from" and "to" currencies in "failures"
    If there are no failures:
        Return a Money struct with the total amount and target currency;
          return nil for error
    Otherwise:
        Return an empty Money struct; return an error message
          including all the failures

With this pseudocode sketched out, let’s write a failing test in money_test.go. This test will be slightly different from the existing tests: it expects an error to be returned and compares the error message with an expected message.

func TestAdditionWithMultipleMissingExchangeRates(t *testing.T) {
    var portfolio s.Portfolio

    oneDollar := s.NewMoney(1, "USD")
    oneEuro := s.NewMoney(1, "EUR")
    oneWon := s.NewMoney(1, "KRW")

    portfolio = portfolio.Add(oneDollar)
    portfolio = portfolio.Add(oneEuro)
    portfolio = portfolio.Add(oneWon)

    expectedErrorMessage :=
        "Missing exchange rate(s):[USD->Kalganid,EUR->Kalganid,KRW->Kalganid,]" 1
    _, actualError := portfolio.Evaluate("Kalganid") 2

    if expectedErrorMessage != actualError.Error() {
        t.Errorf("Expected [%s] Got: [%s]",
            expectedErrorMessage, actualError.Error())
    }
}
1

Expected error message should list each missing exchange rate; note the terminating comma

2

We don’t care about the first return value, so we assign it to the blank identifier

Tip

Go’s implicit semicolon rule requires a trailing comma in composite literals. The trailing comma after the last exchange rate in our error message reflects this syntactic preference of Go.

This test is similar to the two existing test for addition. We expect an error with the detailed message as the second return value from Evaluate method. We ignore the first return value by assigning it to the blank identifier.

We compare the expected and actual error messages directly in our test. We cannot use our assertEqual function as it currently exists because it can only compare Money s. We should improve this assertEqual function; we’ll defer it until the REFACTOR phase.

Tip

In Go, we can assign any return value from a function to an underscore (_). This is the “blank identifier” — it effectively means “we don’t care about this value”.

This code doesn’t compile. If we try to run it, we’ll get an error in money_test.go:

... assignment mismatch: 2 variables but portfolio.Evaluate returns 1 values

To get this test to pass, we first have to change the signature of the Evaluate method to return two values, the second one being an error. How would Evaluate know when to return an error? It would know if one (or more) of the calls to convert failed, because convert is where any missing exchange rates would be detected. This means that we have to change the signature of convert method, too.

Let’s redesign the convert method first, so that it returns a boolean to indicate whether the rate was found or not:

func convert(money Money, currency string) (float64, bool) { 1
    exchangeRates := map[string]float64{
        "EUR->USD": 1.2,
        "USD->KRW": 1100,
    }
    if money.currency == currency {
        return money.amount, true
    }
    key := money.currency + "->" + currency
    rate, ok := exchangeRates[key]
    return money.amount * rate, ok
}
1

Method signature changed to return two values

We modify the signature of convert to add a second return type: a bool. If the “from” and “to” currencies are the same, the conversion is trivial as before: we return the money.amount unchanged and a true as the second return value to indicate success. If the “from” and “to” currencies are different, we look up the exchange rate in our map. We use the success or failure of this lookup, captured in the ok variable, as the second return value of convert method.

Tip

In Go, when we look for a key in a map, the second return value is true if the key was found, false otherwise. Conventionally, the second return value is assigned to a variable named ok — hence the name of this idiom: “comma, ok”.

We have modified convert’s signature; we need to redesign Evaluate too.

import "errors" 1
...
func (p Portfolio) Evaluate(currency string) (Money, error) {
    total := 0.0
    failedConversions := make([]string, 0)
    for _, m := range p {
        if convertedAmount, ok := convert(m, currency); ok {
            total = total + convertedAmount
        } else {
            failedConversions = append(failedConversions,
                m.currency+"->"+currency)
        }
    }
    if len(failedConversions) == 0 { 2
        return NewMoney(total, currency), nil
    }
    failures := "["
    for _, f := range failedConversions {
        failures = failures + f + ","
    }
    failures = failures + "]"
    return NewMoney(0, ""),
        errors.New("Missing exchange rate(s):" + failures) 3
}
1

The errors package is needed to create errors

2

If there are no failed conversions, a nil error is returned as the second value

3

If there are failed conversions, an error listing all the failed conversions is returned as the second value

There are several new lines of code; however, they are a faithful representation of the pseudocode we sketched out earlier. Manufacturing the error message string from the failedConversions slice requires a second for loop, but is conceptually straightforward.

With these changes, we get compilation failures in the the three other tests we have for addition. We get this error message, in triplicate:

... assignment mismatch: 1 variable but portfolio.Evaluate returns 2 values

Because we have changed the signature of Evaluate to return two values, we must also change the existing calls to this method to receive the second value, albeit with a “talk to the hand” blank identifier! One example is shown below:

    actualValue, _ := portfolio.Evaluate("USD") 1
1

Assigning the second return value to the blank identifier indicates we don’t care about errors here

With these changes, all the tests now pass.

Time to refactor: let’s address the assertion if block in our newest test. We’d like to call the assertEqual method, but its signature currently requires two Money objects, whereas we want to compare two `string`s. The body of the method is fine as it is: it compares the two things it’s given and prints a formatted error message if they’re unequal.

Is there a way we could declare the two parameters to assertEqual in a more generic fashion?

Yes, there is. In Go, `struct`s can implement one or more `interface`s. The mechanism of this implementation is rather sublime: if a struct happens to be the receiver for all the methods defined in an interface, then it automatically implements that interface. There is no declaration in code explicitly saying “Hear ye! This struct hereby implements that interface.” (There isn’t a programmatic version of this town crier announcement, either.) Go’s interfaces are an interesting blend of static typechecking and dynamic dispatch.

Important

An interface in Go is implemented by anything — user-defined struct or built-in type — that implements all the methods in the interface.

Of particular interest is the empty interface, which defines exactly zero methods. Because the empty interface has no methods, it is implemented by every type.

Tip

In Go, the empty interface{} is implemented by every type. 2

Since the empty interface is implemented by every type, we can change the signature of the assertEqual method to accept an expected value and an actual value, both of which are of the type interface{}. We can then happily pass in two string`s or two `Money s, as we need:

func assertEqual(t *testing.T, expected interface{}, actual interface{}) { 1
    if expected != actual {
        t.Errorf("Expected  [%+v] Got: [%+v]", expected, actual)
    }
}
1

The signature of this method is changed to accept two interface{} s, instead of two Money s

We can now replace the if block in TestAdditionWithMultipleMissingExchangeRates with a call to this modified assertEqual method:

func TestAdditionWithMultipleMissingExchangeRates(t *testing.T) {
...
    assertEqual(t, expectedErrorMessage, actualError.Error()) 1
}
1

Call to the modified assertEqual method. Note that the last parameter is now actualError.Error(), to ensure type consistency with the second parameter

Neat! The tests are still green, and we have fewer lines of code. We have accomplished the three items listed in Table 10-1.

There is still duplication in the code: the bits where we create the key in both convert and Evaluate. We need to simplify our code. We’ll add it to our feature list.

JavaScript

We’d like to throw an Error from evaluate with a detailed message when one or more exchange rates are not found. Let’s write a test in test_money.js to describe the specific message this exception should have.

testAdditionWithMultipleMissingExchangeRates() {
    let oneDollar = new Money(1, "USD");
    let oneEuro = new Money(1, "EUR");
    let oneWon = new Money(1, "KRW");
    let portfolio = new Portfolio();
    portfolio.add(oneDollar, oneEuro, oneWon);
    let expectedError = new Error(
        "Missing exchange rate(s):[USD->Kalganid,EUR->Kalganid,KRW->Kalganid]"); 1
    assert.throws(function() {portfolio.evaluate("Kalganid")}, expectedError);
}
1

Expected error message should list each missing exchange rate

This test is similar to the existing tests for addition, with the notable difference that we are trying to evaluate the Portfolio in “Kalganid”. The assert.throws takes a reference to an anonymous function that calls the evaluate function as the first parameter, and the expected error as the second parameter.

Important

In JavaScript, we don’t call the method-under-test as part of the assert.throws when we expect an exception to be thrown; otherwise the assert statement would itself fail to execute successfully. Instead, we pass an anonymous function object as the first parameter which calls the method-under-test.

This test fails because our evaluate method currently doesn’t throw the expected exception:

AssertionError [ERR_ASSERTION]: Missing expected exception (Error).
...
  code: 'ERR_ASSERTION',
  actual: undefined,
  expected: Error:
    Missing exchange rate(s):[USD->Kalganid,EUR->Kalganid,KRW->Kalganid]

We could write a trivial (“silly”) conditional statement at the top of the evaluate method to get the test to pass. We could then write yet another test to force us towards the non-trivial (“better”) implementation:

evaluate(currency) {
    //////////////////////////////////////
    // We *could* do this; but let's not!
    //////////////////////////////////////
    if (currency == "Kalganid") {
        throw new Error(
          "Missing exchange rate(s):[USD->Kalganid,EUR->Kalganid,KRW->Kalganid]");
    }
...
}

Let’s see if we can speed things up by aiming for the non-trivial implementation right away.

In Chapter 9, we saw that when we query a Map in JavaScript with a key that doesn’t exist, we get an undefined return value. We could implement convert in a similar way: return the converted amount when the rate is found, undefined otherwise.

    convert(money, currency) {
        let exchangeRates = new Map();
        exchangeRates.set("EUR->USD", 1.2);
        exchangeRates.set("USD->KRW", 1100);
        if (money.currency === currency) {
            return money.amount; 1
        }
        let key = money.currency + "->" + currency;
        let rate = exchangeRates.get(key);
        if (rate == undefined) {
            return undefined; 2
        }
        return money.amount * rate; 3
    }
1

When “converting” Money from a currency to the same currency, simply return the amount as the result

2

When no exchange rate is found, return undefined as the result

3

When an exchange rate exists, use it to compute the converted amount

In evaluate, we can check each call to convert while reduce`ing the `moneys array. If any conversions result in an undefined value, we note down the missing conversion key (i.e the “from” and “to” currencies) in an array. At the end, we either return a new Money object as before if every conversion worked, or throw an error whose message contains the missing conversion keys if there were failures.

    evaluate(currency) {
        let failures = [];
        let total = this.moneys.reduce( (sum, money) => {
            let convertedAmount = this.convert(money, currency);
            if (convertedAmount == undefined) {
                failures.push(money.currency + "->" + currency);
                return sum;
            }
            return sum + convertedAmount;
          }, 0);
        if (failures.length == 0) {
            return new Money(total, currency); 1
        }
        throw new Error("Missing exchange rate(s):[" + failures.join() + "]"); 2
    }
1

If there are no failures, a new Money object with the correct amount and currency is returned

2

If there are conversions failures, an error listing all the failed conversions is returned

The tests are all green and we’ve accomplished the items in Table 10-1.

There is a subtle unpleasant odor in our code, however. The duplication where we create the conversion key in both convert and evaluate is the source of this odor. We’ll add this clean-up item to our feature list.

Python

We’d like to raise an Exception when evaluate fails due to missing exchange rates. In its message, the exception should describe all the missing exchange rate keys (ie. the “from” and “to” currencies). Let’s start with a test that validates this behavior.

Important

Python has a refined class hierarchy for exceptions, errors, and warnings. All user-defined exceptions should extend Exception.

    def testAdditionWithMultipleMissingExchangeRates(self):
        oneDollar = Money(1, "USD")
        oneEuro = Money(1, "EUR")
        oneWon = Money(1, "KRW")
        portfolio = Portfolio()
        portfolio.add(oneDollar, oneEuro, oneWon)
        with self.assertRaisesRegex(
            Exception,
            "Missing exchange rate(s):[USD->Kalganid,EUR->Kalganid,KRW->Kalganid]",
        ):
            portfolio.evaluate("Kalganid")

This test is similar to the existing tests for addition, with a couple of differences. First: we are attempting to evaluate a Portfolio in “Kalganid”, for which no exchange rates exist. Second: we expect the evaluate method to throw an exception with a specific error message that we verify in the assertRaisesRegex statement.

Important

assertRaisesRegex is one of the many useful assertion methods defined in Python’s TestCase class. Since our exception string has several characters that have special meaning in regular expressions, we escape them using the backslash character.

The test fails with two exceptions. First, there’s the KeyError which we expect: there is no exchange rate key involving the “Kalganid” currency. The second error is the assertion failure we sought to cause:

FAIL: testAdditionWithMultipleMissingExchangeRates (__main__.TestMoney)
----------------------------------------------------------------------
KeyError: 'USD->Kalganid'

During handling of the above exception, another exception occurred:

...
AssertionError:
  "Missing exchange rate(s):[USD->Kalganid,EUR->Kalganid,KRW->Kalganid]"
    does not match "'USD->Kalganid'"

This reveals that our test is throwing an Exception, however, the message in the Exception does not match what our test demands. Notice that the message in the Exception that is thrown is “USD→Kalganid" — which is at least one part of our desired error message. We have a head start!

The “USD→Kalganid” message is in the KeyError Exception that’s raised when we look for a missing key in the dictionary of exchangeRates. Could we capture all such messages in evaluate and raise an Exception with the manicured message?

We need to modify our evaluate method to respond to Exceptions arising from its calls to __convert. Let’s unroll the lambda expression into a loop and add a try ... except block to capture any failures. If there are no failures, we return a new Money object as before. If there are failures, we raise an Exception whose message is a comma-separated list of the stringified KeyError exceptions that are caught:

    def evaluate(self, currency):
        total = 0.0
        failures = []
        for m in self.moneys:
            try:
                total += self.__convert(m, currency)
            except KeyError as ke:
                failures.append(ke)

        if len(failures) == 0:
            return Money(total, currency) 1

        failureMessage = ",".join(str(f) for f in failures)
        raise Exception("Missing exchange rate(s):[" + failureMessage + "]") 2
1

If there are no failures, a new Money object with the correct amount and currency is returned

2

If there are conversions failures, an Exception listing all the failed conversions is returned

When we run our test now, we get an AssertionError:

AssertionError:
  "Missing exchange rate(s):[USD->Kalganid,EUR->Kalganid,KRW->Kalganid]"
    does not match
  "Missing exchange rate(s):['USD->Kalganid','EUR->Kalganid','KRW->Kalganid']" 1
1

Actual and expected values differ by the presence/absence of the ' single-quote character

Ah! The difference is that the stringified KeyError contains single-quotes that are missing from our desired message.

So close and yet so far! We are tempted to change our test to add the single-quotes around each missing exchange-rate key. Should we do that?

On occasion, there may be valid reasons to change our requirements to match our result — if the change isn’t that overwhelming, or the feature isn’t that critical. We could mount both those arguments against further changes to the evaluate method in this case.

However, there is something icky about moving the goalposts after the game has started. And we are so close! A quick examination of the documentation of KeyError reveals that, like all subclasses of BaseException, it has an args property which contains a list of string arguments provided when the exception object is created. The first message in this list — at index 0 — is the message we seek. A simple change to the way we assemble our failureMessage can fix our problem:

        failureMessage = ",".join(f.args[0] for f in failures) 1
1

Using f.args[0] instead of str(f) removes the single-quote characters

All the tests are now green and we’ve accomplished what we set out to do: the items in Table 10-1. However, that icky feeling that things aren’t great — that we don’t have the simplest code that works — is still with us. For one thing: we unrolled our compact lambda expression into a verbose loop.3 For another: we reached into the depths of a built-in exception class to craft our error message.

We’ll add an item to our list to refactor the part of our code dealing with exchange rates.

Committing our changes

The error handling we added to our code merits a commit to our local Git repository. Let’s do this:

git add .
git commit -m "feat: improved error handling for missing exchange rates"

Where We Are

We have added error handling to the way we evaluate Portfolios. The resilience this brings to our code is no mean feat.

In doing so, however, we’ve gradually become aware of the clumsy way in which we’ve modeled exchange rates thus far. By keeping the implementation not just within Portfolio but within the evaluation of a Portfolio, we’ve strayed away from the elegance of simplicity.

Let’s add a feature to our list to improve our implementation of exchange rates.

5 USD x 2 = 10 USD

10 EUR x 2 = 20 EUR

4002 KRW / 4 = 1000.5 KRW

5 USD + 10 USD = 15 USD

Separate test code from production code

Remove redundant tests

5 USD + 10 EUR = 17 USD

1 USD + 1100 KRW = 2200 KRW#

Determine exchange rate based on the currencies involved (from → to)

Improve error handling when exchange rates are unspecified

Improve the implementation of exchange rates

Allow exchange rates to be modified

1 “Kalganid” is a fictitious currency in Isaac Asimov’s “Foundation” series.

2 To test empty Go interfaces, try this useful example in your browser: https://tour.golang.org/methods/14

3 There is no trivial way to catch exceptions within a Python lambda. There was an enhancement proposal to Python — PEP 463 — that was about this very feature. However, that proposal was rejected in 2014. See https://www.python.org/dev/peps/pep-0463/

4 As with many other software terms, Martin Fowler’s website has a useful page on the topic of “CodeSmell” https://www.martinfowler.com/bliki/CodeSmell.html

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.119.105.239