Vocabulary
Vocabulary
- Exception
- Error
- Class Hierarchy
- Rescue
Learning Goals
- Understand why we need to plan for errors and how to handle exceptions
- Implement sad path testing and describe why it’s important
- Use Rails’ built-in methods for rescue
Error Handling Vs. Debugging
What’s the difference between error handling and debugging?
Error Handling: doing something about expected problems Debugging: solving problems we were less prepared for Debugging could inform future error handling
Breakout Room Discussion - 10 minutes
Lets talk about some of the common errors you’ve seen thus far in your development. These can be errors you’ve seen in mod 1 in pure ruby or errors that you’ve seen as you’ve begun developing professional quality rails applications ie: ActiveRecord, Routing, ApplicationController, Status Codes, etc.
- Make a list of all of the errors you can think of.
- When our Rails app encounters an error right now, what happens?
Why do we care?
- We can get more specific details around what happened, maybe better logging -> easier debugging
- Quickly identify errors
- User friendliness // Trust
- Showing Rails exceptions with a stack trace can be a security vulnerability
Exceptions, and how to handle them
- Error
- Something bad that happened during normal operation of a program.
- Exception
- How our program (Ruby) handles an error, in an object-oriented way. * Exceptions are simply classes that the Ruby library has predefined for us.
Ruby comes with a big list of Exceptions: see this list of classes.
❓ Do any of these look familiar?
Exception
NoMemoryError
ScriptError
LoadError
NotImplementedError
SyntaxError
SignalException
Interrupt
StandardError
ArgumentError
IOError
EOFError
IndexError
LocalJumpError
NameError
NoMethodError
RangeError
FloatDomainError
RegexpError
RuntimeError
SecurityError
SystemCallError
SystemStackError
ThreadError
TypeError
ZeroDivisionError
SystemExit
fatal
Documentation is available for each, ex: StandardError docs
- Rescue
- The process of “saving” an application from an Exception
- Don’t rescue directly from the big
Exception
class - be specific! Stay at or belowStandardError
. Ruby needs some of those errors to function properly!- They are not Pokémon. Do not catch ‘em all.
Process for Handling Errors
- What exception are you trying to handle?
- example: NoMethodError
- Where are you trying to handle it?
- begin/rescue around a line or block of code.
- What should happen next?
- Tell the application what to do or try again.
Practice
Run these lines of code in a new Ruby file for this lesson. Which specific error types do you see?
- “string”.gsub
- “string”.hey_there
- [1, 2].first(“one”)
Create a ruby file called exception_handling.rb
and place the code below in it. Then, let’s run that file.
begin
"String".hey_there # our existing code
rescue StandardError => e
require "pry"; binding.pry
end
? What would happen if you handled all the exceptions in this file, and ran it again?
Sad Path Testing
“Happy” Paths vs “Sad” Paths
- “Happy” Paths ╰( ◕ ᗜ ◕ )╯
- The way an application should work – and often does Most user stories & feature tests (so far) written this way
- “Sad” Paths (◞‸◟;)
- Incorrect usage of our application that has already been solved for
- Error messages
- “Gentle guidance” for the user
Then what are edge cases?
Edge case: Less common input, can sometimes break functionality
Sad Path: Expected, but handled input
Bottom line: your tests should include both: happy paths for all, sad paths for inputs, and maybe 1-2 edge cases (if applicable).
Error Handling in Rails
Setup
We’ll be using the error-handling-start
branch of the Set List API for this lesson.
Sad Path User Story
As an API user
When I make a request for a song that doesn't exist
Then I am returned an error message saying "Couldn't find Song with 'id'=1" and a status code of 404
And I do not see the exception stack trace nor any other extraneous information.
Let’s run the sad path test from /spec/requests/api/v1/songs_request_spec.rb
in isolation:
describe "Songs endpoints" do
...
describe 'sad paths' do
it "will gracefully handle if a Song id doesn't exist" do
get "/api/v1/songs/123489846278"
expect(response).to_not be_successful
expect(response.status).to eq(404)
data = JSON.parse(response.body, symbolize_names: true)
expect(data[:errors]).to be_a(Array)
expect(data[:errors].first[:status]).to eq("404")
expect(data[:errors].first[:message]).to eq("Couldn't find Song with 'id'=123489846278")
end
end
end
What is the outcome of this test? Is it currently passing or failing?
It’s not a trick question, and if you said “failing,” you’re correct! We do want to return an error response of some kind, but our application never gets the chance to do so, because it is rudely interrupted by a raised Exception.
Remember, Exceptions are a type of error that ‘fails loudly.’ While this might be the desired behavior, we should still handle the Exception gracefully for a better user experience.
Following the stack trace, let’s put a pry
in the Api::V1::SongsController
:
def show
require 'pry'; binding.pry
render json: SongSerializer.format_song(Song.find(params[:id]))
end
Running Song.find(params[:id])
here will return the following:
ActiveRecord::RecordNotFound: Couldn't find Song with 'id'=1
We can implement error handling in this controller action by rescuing from the specific Exception that was raised in the case of our sad path test.
def show
begin
render json: SongSerializer.new(Song.find(params[:id]))
rescue ActiveRecord::RecordNotFound => exception
require 'pry'; binding.pry
end
end
Run your test and verify that this part works by hitting the pry.
Let’s get the test to pass by returning a json error response within the rescue
!
def show
begin
render json: SongSerializer.new(Song.find(params[:id]))
rescue ActiveRecord::RecordNotFound => exception
render json: {
errors: [
{
status: "404",
message: exception.message
}
]
}, status: :not_found
end
end
A Second Approach to Error Handling
Before we implement error handling for our create
endpoint, let’s see what currently happens.
Songs require an artist_id
.
Make a POST
request to your Songs
endpoint and use the following request body that doesn’t include an artist_id
.
{
"title": "Example Song",
"length": 12
}
You should see an API response that starts similarly to the following:
{
"status": 422,
"error": "Unprocessable Content",
"exception": "#<ActiveRecord::RecordInvalid: Validation failed: Artist must exist>",
"traces": {
"Application Trace": [
{
"exception_object_id": 19040,
"id": 12,
"trace": "app/controllers/api/v1/songs_controller.rb:11:in `create'"
}
],
"Framework Trace": [
{
"exception_object_id": 19040,
"id": 0,
"trace": "activerecord (7.1.3.4) lib/active_record/validations.rb:84:in `raise_validation_error'"
},
{
"exception_object_id": 19040,
"id": 1,
"trace": "activerecord (7.1.3.4) lib/active_record/validations.rb:55:in `save!'"
},
{
"exception_object_id": 19040,
"id": 2,
"trace": "activerecord (7.1.3.4) lib/active_record/transactions.rb:313:in `block in save!'"
},
{
"exception_object_id": 19040,
"id": 3,
"trace": "activerecord (7.1.3.4) lib/active_record/transactions.rb:365:in `block in with_transaction_returning_status'"
},
...
]
}
}
As an API user this is a very messy response. It’s way longer than it needs to be and could be much cleaner.
There are two approaches we can take to cleaning this up. The first is to make use of the begin/rescue syntax we use for the show
endpoint.
Begin/Rescue Error Handling
Replace your create
endpoint with the following, and make another POST request.
def create
begin
render json: song = Song.create!(song_params), status: 201
rescue ActiveRecord::RecordInvalid => exception
render json: {
errors: [
{
status: "422",
message: exception.message
}
]
}, status: :unprocessable_entity
end
end
You’re API response should now look something like this. Much nicer!
{
"errors": [
{
"status": "422",
"title": "Validation failed: Artist must exist"
}
]
}
If/Else Error Handling
Now, replace your current create
endpoint with the following code. Send the same API request again and notice that again you have a much more user friendly response!
def create
song = Song.new(song_params)
if song.save
render json: song, status: 201
else
render json: {
errors: [
{
status: "422",
message: song.errors.full_messages
}
]
}, status: :unprocessable_entity
end
end
What is different between this approach and the approach for the show
endpoint?
Here we have switched to using new
and save
to create a new entity. And we are able to make use of an if/else statement because song.save
returns us a boolean to let us know if that method was successful. We can see this from the save docs. The find
method we used earlier behaves differently and just throws an exception that we need to use rescue
to recover from. We can see this from the find docs
Error Handling and Validations
Let’s review your Data Validations homework and think about how data validations fit in with error handling.
With you partner, discuss the following two questions:
- What are data validations?
- Where do validations happen? (DB, Rails application, FE, etc)
- When do validations happen?
- Why is it important to validate our data?
When one of our validations fails, that’s one reason for song.save
to return False
.
Try it out!
In your breakout rooms, add a model validation and implement the sad path for these scenarios. Don’t forget to test in the model AND request spec:
- Songs must be created with a title present
- Songs must be created with play_count and length, and these attributes must be numerical
- The above numericality requirements should be true for updating a song record as well.
Solutions can be found on the error-handling-complete
branch of Set List
Additional Data Validations Practice
Add model tests and validations for:
Basics
- Add a validation that validates the uniqueness of an Artist’s name
- Add a boolean column, and then a validation to check for the presence of a boolean value for one of your models.
Advanced
- Add a new column to the songs table that has a validation that only runs on an
update
- Add a custom validation method to assign a default length attribute for a Song when none is provided
Checks for Understanding
- What is an example of a Sad Path, and how does it differ from a Happy Path?
- Why should we handle exceptions?
- Why is it best practice to handle specific exceptions and never rescue directly from the big
Exception
class?