Check your return values: the Web

Mike Burns

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.

Design-driven Error Messages

Design-driven development is the art of building a product from the user’s perspective first, then doing the bare minimum to make that work. In a Rails app that would be HTML/ERb and CSS, then just enough JavaScript and controller glue to make that work, and just enough model and service object code to make the controllers work. If this sounds obvious, well, you should have seen what we did before!

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 left the 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.

The exception’s last stand

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:

a sign in screen with email, password, and a button

After a conversation on error handling, this is the result:

a sign in screen with a global message, ambigous about the specifics of the error

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 User.authenticated_by.

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?

Bubbles

The 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.

class User < ActiveRecord::Base
  ##
  # Return the authenticated user.
  #
  # @param [Hash] args The authentication credentials.
  # @option args [String] email The user-entered email address.
  # @option args [String] password The user-entered password.
  # @return a [User], or +nil+ if no matching user exists.
  def self.authenticated_by(args)
    user = find_by(email: args[:email)
    enc_passwd = BCrypt::Password.create(args[:password])

    if enc_passwd == user&.encrypted_password
      user
    end
  end
end
The method uses the `ActiveRecord::Base.find_by` method to pick the user by email address, then the lower-level BCrypt methods to compare the password. If they match, return the user; otherwise return `nil`.

This calls three methods: #find_by in ActiveRecord, BCrypt::Password.create, and BCrypt::Password#==.

The #find_by method returns nil if it cannot find the desired row; this is in comparison with #find, which raises ActiveRecord::RecordNotFound. The case in which #find_by returns 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 nil.

The #find_by method can raise an exception, however! For example, the users 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 instance of PG::Error. We choose to rely on the ActionPack rescue_from 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 PG::Error when calling 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 BCrypt::Password.create and BCrypt::Password#==. The .create method always returns an object wrapping the hashed password, when given an object that responds to #to_s (such as a String). The #== comparison method always returns a truthy value when given a stringable argument. We depend on them performing as advertised for our input.

The .create and #== 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.

Intermezzo

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.