Using Exceptions to manage control flow in Rails Controllers

by on

Ah yes, the Rails Controller, a source of much contention among Rails developers. So many different ways to manage control flow, load objects, respond in standard and erroneous ways. My opinion up until recently was "I'll just put a bunch of conditionals in there for different situations."

Recently, I've been working more on API endpoints and so responding with nice error messages has been more of a priority. I started using Exceptions more throughout my code thanks to Avdi Grimm, and I recently wrote and action that I'm particularly proud of. Check it out:

# This controller's job is to exchange twitter credentials for Shortmail credentials
class TwitterReverseAuthController < ApplicationController
# First, let's make our own subclass of RuntimeError
class Error < RuntimeError; end

def api_key_exchange
# Here are our required parameters. If any are missing we raise an error
screen_name = params.fetch(:screen_name) { raise Error.new('screen_name required') }
token = params.fetch(:oauth_token) { raise Error.new('oauth_token required') }
secret = params.fetch(:oauth_secret){ raise Error.new('oauth_secret required') }

# OK now let's authenticate that user. If we can't find a valid user, raise an error
@user = User.by_screen_name(screen_name).where(
:oauth_token => token,
:oauth_secret => secret
).first or raise Error.new('user not found')

# Now we'll build a device. I'm not catching an exception on create! here because
# It should never fail. (I.e. a failure is actually a 500 because we don't expect it)
@device = Device.find_or_create_by_token!(
params.slice(:token, :description).merge(:user_id => @user.id)
)

render :json => { :api_key => @device.api_key }

# Now I can simply catch any of my custom exceptions here
rescue Error => e
# And render their message back to the user
render :json => { :error => e.message }, :status => :unprocessable_entity
end
end

Here are the things I really like about this solution:

  • The happy path is really clear because there's no if/else branching
  • Errors are really obvious because I'm raising an exception (as opposed to "else render json that complains" which looks like a render which is not immediately apparent as a failure)
  • It's super easy to handle the errors in the same way. Instead of repeating the json render with a different message all throughout the method (i.e. it's DRY)

How's this look to you? How do you organize controller control flow?

Comments

Jim Gay
Interesting.
But this will only catch one error at a time, so you'd return the result when the first raise is hit even if multiple parameters are missing.

And Avdi points out that there is performance overhead with raising exceptions. Did you opt not to worry about that? Why not just collect an array of errors and test for their presence?
Nick Gauthier
I think the performance overhead isn't an issue here. I expect users only to get it wrong while they're figuring it out. If this was on a form or something, then I'd raise an exception if the user is invalid, and I'd have all the params right there. But this is an API call, so it's more of a "while I'm developing I get errors".

Also keep in mind it's more performant the sooner I can bail out of processing the request :-)

Collecting and returning all errors makes sense for fetching the params, but if I can't find a user I can't proceed with the rest of the call. So I have to stop execution anyways.
codecraig
I've used a similar approach although I was a little bit more granular in the HTTP response codes. For example I'd send back a 404 if an entity was not found.
james
- There's too much going on this method.
- From my limited reading of Avdi's blog posts / presentation slides, I don't feel that he would agree with using exceptions this way.
- If you really want to subclass RuntimeError, I would choose a more descriptive name.

An alternative: https://gist.github.com/1243758
Nick Gauthier
@codecraig great point on the 404. Could be an additional subclass that has the status set.

@james I'm looking up a user and creating a device. This is the minimum amount that can be done in a nested route on a create action. I disagree with your extraction because it will be only used in this single situation and obfuscates the method.

I might make a User.authenticate_via_oauth(screen_name, token, secret) that returns nil or a user, but creating a separate class is overkill here IMO.
Avdi Grimm
Assorted thoughts...

* So many wonderful fetches! My cup overflows.

* Nice use of 'or' as a statement modifier too, putting the error case last, where it belongs :-)

* Maybe I'm missing something... why are you explicitly instantiating the exceptions? Why not 'raise Error, "some message"'?

* Because this is in an API endpoint, it makes sense to use exceptions liberally. We expect humans to make occasional mistakes. Conversely, we expect API clients to be fixed when they make mistakes, and then to never make that mistake again. We also generally don't need to present "Here's what you said, maybe you meant something else..." type feedback to robots, so we don't need to worry about keeping context around that the exceptions might throw away.

* As james pointed out, I do think there's a lot going on in this method. E.g. I personally don't think a #where() call has any business in a controller, and then you've got a #first on the end of that, which makes it a third-order digression into querying minutia.

@Jim: Exception performance is not on the order to worry about in a case like this. It only becomes a worry inside tight loops. Here network latency is going to drown any latency the exceptions add.
Nick Gauthier
Forgot about "raise Error, "message"" :-)

Yeah the "where" on user should be "User.authenticate_via_oauth(screen_name, token, secret) => user or nil"

Thanks for the feedback everyone. Glad to know the controller is still an interesting area to experiment with.
bryanl
Why didn't you just use a goto? It would be more explicit, and the exact same thing you are trying to accomplish here.
Nick Gauthier
Lack of self confidence.
Brian Cardarella
http://i.imgur.com/HDhaa.png
Brian Cardarella
That was a reference to spaghetti code incase it was over anybody's head.
Nick Gauthier
I made a gist:

https://gist.github.com/1245259

fork it!
Brian Cardarella
Nick,

https://gist.github.com/1245327
Nick Gauthier
@Brian:

https://gist.github.com/1245342

your incorrect assumption is that every api call fails. When 1 per 1 million calls fail, exceptions are 3% slower, which is acceptable for readability purposed.
Nick Gauthier
I wanted to log this response post here for people reading the comments:

http://www.enlightsolutions.com/articles/catch-all-exception-handling-is-not-flow-control/
rubiii
Nick, your solution is pretty interesting. Thanks for posting this.

It feels like you're using Exceptions for flow control. At least I don't see missing parameters as an exceptional case. It's possible and expected to happen. That's why we test for it.
rubiii
Ok, so now that I've actually read the title of your post ... ;) Using Exceptions for control flow feels like writing goto statements again?!
Patrick
I just posted a deeper refactoring of this code and thought it might be of interest. I'm curious to get others' thoughts on this approach.

Thanks.
Nick Gauthier
@Patrick thanks! You're the third person to suggest a domain model as a solution. I like the idea of making the model encapsulate the multiple actions and have the controller simply perform the standard Create action on the domain model.
blog comments powered by Disqus