Created
February 20, 2015 16:06
-
-
Save noelwelsh/9cacc8683bf3231b9219 to your computer and use it in GitHub Desktop.
Robust Error Handling in Scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import scalaz.\/ | |
import scalaz.syntax.either._ | |
object Example2 { | |
// This example simulates error handling for a simple three tier web application | |
// | |
// The tiers are: | |
// - the HTTP service | |
// - a user authentication layer | |
// - a database layer | |
// | |
// Each stage defines its own error types | |
// A few type aliases for clarity | |
type Query = String | |
type Host = String | |
type Username = String | |
type Password = String | |
final case class User(username: Username, firstName: String, lastName: String, password: Password) | |
object HttpService { | |
sealed trait HttpError extends Exception | |
final case class CouldNotAuthenticate(username: Username) extends HttpError | |
// We don't store any infomration about the cause of an internal | |
// error as we don't want to leak internal information to | |
// attackers. | |
final case object InternalError extends HttpError | |
// After calling login we would have a conversion to some kind of | |
// HTTP response (not shown). The type is: | |
// \/[HttpError, User] => HttpResponse | |
// or in abstract | |
// A => B | |
// Readers of Essential Scala should recognise this as a fold | |
def login(username: Username, password: Password): \/[HttpError, User] = | |
// We use leftMap to convert the error type from the layer below | |
// into the error type used at this layer. This is boilerplate to | |
// some extent, but there are good reasons for changing how we | |
// report errors, which we see here as we want to avoid leaking | |
// information to attackers. In a real system we'd also have | |
// some kind of logging to record information we need for | |
// debugging. | |
AuthenticationService.login(username, password).leftMap { | |
// Don't leak information about why a login attempt | |
// failed. Letting an attacker know they have found a valid | |
// username allows them to focus attacks. | |
case AuthenticationService.NotFound(username) => | |
CouldNotAuthenticate(username) | |
case AuthenticationService.BadPassword(username, password) => | |
CouldNotAuthenticate(username) | |
case AuthenticationService.DatabaseError(error) => | |
InternalError | |
} | |
} | |
object AuthenticationService { | |
sealed trait ServiceError | |
final case class BadPassword(username: Username, password: Password) extends ServiceError | |
final case class NotFound(username: Username) extends ServiceError | |
final case class DatabaseError(error: DatabaseService.DatabaseError) extends ServiceError | |
def login(username: Username, password: Password): \/[ServiceError, User] = | |
for { | |
user <- DatabaseService.findUserByUsername(username).leftMap { | |
case DatabaseService.NotFound(u) => NotFound(u) | |
case e @ DatabaseService.CouldNotAuthenticate(u, p) => DatabaseError(e) | |
case e @ DatabaseService.CouldNotConnect(h) => DatabaseError(e) | |
} | |
_ <- checkPassword(user, password) | |
} yield user | |
def checkPassword(user: User, password: Password): \/[ServiceError, User] = | |
if(user.password == password) | |
user.right | |
else | |
BadPassword(user.username, password).left | |
} | |
object DatabaseService { | |
sealed trait DatabaseError | |
final case class NotFound(username: Username) extends DatabaseError | |
final case class CouldNotConnect(host: Host) extends DatabaseError | |
final case class CouldNotAuthenticate(username: Username, password: Password) extends DatabaseError | |
def findUserByUsername(username: Username): \/[DatabaseError, User] = | |
// Simulate network and system errors | |
Math.random() match { | |
case p if p < 0.1 => CouldNotConnect("database.server").left | |
case p if p < 0.2 => CouldNotAuthenticate("user", "password").left | |
case _ => | |
username match { | |
case "noelw" => User("noelw", "Noel", "Welsh", "soverysecret").right | |
case "daveg" => User("daveg", "Dave", "Gurnell", "\\m/").right | |
case _ => NotFound(username).left | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is a great example, but I am really curious about one thing: since
login
method is impure, how would you test it? Even if we accept that impurity, we can't overrideDatabaseService
object, becauseAuthenticationService
is an object itself. I can think of a few solutions (e.g. making AuthService a trait, or passing\/[DatabaseError, User]
as a param), but none of them fully satisfies me.