The main take-away from this post is to always check return values. A corollary is to design the user experience to include error messages in a helpful manner.
When following a design-driven development paradigm, it’s easy to excitedly plan out the happy path, ignoring all the error points. This is one of the reasons we find value in pairing an experienced developer with our designers during prototyping or product design sprints: they often know, from experience, what kinds of errors to expect from various flows.
As a common example, when navigating from the login page to the dashboard –
when a user signs in – there is a chance that the user will fail to
authenticate correctly and must be told this. This means that the login form
needs a place for an error message. Since this is a security-related concern,
thought must be given not only to the error message itself (don’t report “you
t off the end of your password”), but also to the placement of the
message: if you flag the “password” field as the error, that reveals that the
user has an account on the system. That is both a privacy concern and also an
indicator that the attacker is onto something and should keep trying passwords
for this username.
As a more tricky example, consider a more complex flow: a mobile app communicating with a HTTP API, with a request that has both a synchronous component and an asynchronous, background job. Let’s take a cart checkout flow, since those are so common: the user enters their payment details, presses “Buy”, and is shown their receipt. So much can happen in between: the payment details could be expired; the payment processor could have an error; the product could go out of stock; the payment authorize could succeed (synchronously) but the capture fails (asynchronously), leading to a synchronous “everything OK” response but later a failure asynchronous response; the phone’s network connection could drop with a successful payment processed by the server, but no response coming back within a timeout. On top of all this, the user might close their app at any time.
These are just some examples, to highlight the importance of not only communicating a useful error message to the user in an appropriate manner, but also considering all the failure points during the initial design process.
From here on we’ll get much more technical. We will consider the user authentication example in more depth, and then the payment checkout example for further refinement of the idea.
In order to show the error message in the view, you have to handle it in the controller.
Let’s talk Rails. In a modern-day Rails app there are a few “just before the view” points: the controller action, the mailer action, the GraphQL resolver, the background job command, the Rack middleware, and more. These are methods that orchestrate the model/service layer and the rendering layer. And it is here where the error message must coalesce. Here is the final resting point of all the chaos that was required to perform the user’s action, and now we must prepare the result as if it were effortless.
It is here – and, with some exceptions, here alone – that you must rescue exceptions. It is here that you must check the return values of your model and service methods.
The first example is authentication. You and the design team come up with a standard sign-in form:
After a conversation on error handling, this is the result:
Your controller has two actions: a
new that renders the login form, and a
create that orchestrates the authentication. The
create fetches a user by
the params, sets the session, and redirects. The
new form can show an error
from the flash.
class SignInsController < ApplicationController def new end def create if user = User.authenticated_by(user_params) session[:user_id] = user.to_param redirect_to dashboard_path else flash.now[:error] = t(".unknown_or_mismatched_account") render :new end end private def user_params params.require(:user).permit(:email, :password) end end
Note that conditional:
if User.authenticated_by(user_params). You
don’t assume that the email and password match; instead you first check the
return value from
The user interface drove the error reporting: to avoid leaking information, there is one place in the UI for an error message, and all it can say is “unknown account or incorrect password”.
This “just before the view” point looks great, but what lurks below?
User.authenticated_by method passes work off to the BCrypt library. These
external sources can raise exceptions. Let’s look at the code then talk
through the exceptions.
#find_by method returns
nil if it cannot find the desired row; this
is in comparison with
#find, which raises
The case in which
nil is where the desired email address
is not in our database; this is a user error, and therefore should be reported.
We signal this by returning
#find_by method can raise an exception, however! For example, the
table could be missing, the database connection could have dropped, or any
other database-related, system-level error might occur. These will all raise an
PG::Error. We choose to rely on the ActionPack
defaults in our controller, instead of handling the error manually. It is
important to note that the error is handled by Rails (in the form of
Exception), and that we can rely on it; otherwise, this exception would
crash our app for all users.
Philosophically, the reason we choose not to care about the
User.authenticated_by is because it represents a programmer error.
Nothing in the user flow specifies showing the user an unexpected,
programmer-caused error message. Therefore we can rely on Rails to swallow it
for us (and our exception tracker to notify us as appropriate), and otherwise
we can ignore it in our “just before the view” code.
Similar thoughts can be said about
.create method always returns an object wrapping
the hashed password, when given an object that responds to
#to_s (such as a
#== comparison method always returns a truthy value when given a
stringable argument. We depend on them performing as advertised for our input.
#== methods can raise, though. If you pass an optional
:cost argument to
.create, it can raise if you give it an out-of-range
value; a programmer error to be caught by the test-driven development process,
and not exposed to the user. Similarly both can raise if passed an object that
does not respond to
#to_s or if the underlying BCrypt C library fails to
generate a valid salt. There’s no recovering from this: these are issues that
will block the user entirely, and it is up to us to let these programmer errors
fall through to the last-resort error handling. We do this with intention: we
have weighed the exceptions and how they can appear, and decided that the UI
calls for them to continue to bubble up.
This completes the first example. A user can attempt to authenticate and can be shown failures they can fix, general status errors, and system errors, in a secure manner.
Error handling starts in the design, requires the collaboration of the UX and development team, and often requires more complicated logic.
In the next section we’ll dive into the API backing a mobile checkout experience, combining synchronous and asynchronous processing.