Click To Skip To Main Content
 
Return to blog

Elixir + Phoenix: How to Raise Custom HTTP Error Responses in Your REST API

By adding a few lines of code, you can return a custom HTTP error response (with status code and custom message/payload) in your Elixir + Phoenix REST API, at any time in the request-response lifecycle.

Created 2024-04-06. Modified 2024-04-09.

Example code written using Phoenix v1.7.11 (The code will also work on Phoenix <= v1.6 with one minor change that is described in this tutorial.)


Update (2024-04-08): I have received a considerable amount of feedback to this post that has been less than enthusiastic, with quite a few readers mentioning that aborting the code early is a subpar method of control flow. With that in mind, if you have the option of using something more idiomatic with less "magic", you should probably use that instead. I'm leaving this post here as an escape hatch for anyone who may need to use the technique described.


NOTE: This blog post has a companion repo here. You can clone it you want to try out the code in this post. Make sure to check out the proper branch for the version of Phoenix you want to use (there are branches for phoenix-1.6 and phoenix-1.7).


Table Of Contents

  1. Introduction
  2. Show Me the Code!
  3. Conclusion
 

Introduction

I've been using Phoenix to make REST APIs lately. The experience has been good overall, but there is one feature that I've been missing: The ability to abort early in the request-response lifecycle, and return (for example) a HTTP 403 response with a custom message in the response body.

This feature is built into other web frameworks I've worked with (e.g. Django has return HttpResponseForbidden()), so I wanted to share how I was able to easily extend this same functionality to Phoenix.

The examples in this post use HTTP 403 (Forbidden), but you can use any valid HTTP status code.

This tutorial is mostly intended for those who are making APIs, but the concepts could easily be applied to a more conventional web application as well.


Here's a contrived example: Let's say we have an API endpoint, but we don't want Bob to be able to access it. If Bob makes a request to this endpoint, we want to exit early so that we don't have to run all that extra code for silly old Bob.

Here's what our controller code might look like:

defmodule YourProjectWeb.PageController do
  use YourProjectWeb, :controller

  def home(conn, %{"name" => name} = _params) do
    if name == "Bob" do
      # This feature doesn't work properly yet, but it will soon...
      raise YourProject.PlugException, plug_status: 403, message: "No Bobs allowed."
    end

    do_extra_stuff_for_people_who_are_not_bob()

    json(conn, "Welcome, #{name}!")
  end
end

When we're done, anybody named Bob will receive a JSON response with a HTTP status code of 403, with a body that looks like this:

"No Bobs allowed."

The specific format can be customized as needed. This is a good time to note that the message in your exception doesn't have to be a string. It can be any JSON-serializable value (e.g. a map or a list). For example:

raise YourProject.PlugException, plug_status: 403, message: %{errors: ["No Bobs allowed."]}

{"errors": ["No Bobs allowed."]}
 

Show Me the Code!

We'll need to add 2 small snippets of code to make things work as intended:

  • First, we'll add a custom exception that accepts the plug_status and message arguments.

  • Then, we'll modify our view-layer code so that Phoenix will render our custom messages when it sends the response.

Define a Custom Exception

I generally put my custom exceptions in lib/your_project/exceptions/. The specific location isn't set in stone (that's a rant for another time) but this new module needs to go somewhere, so we'll put it there.

lib/your_project/exceptions/plug_exception.ex

defmodule YourProject.PlugException do
  defexception [:plug_status, message: ""]
end

In this module, we've defined an exception which accepts a plug_status argument (which is required) and a message argument (which is optional).

The plug_status argument represents the specific HTTP status code we want to return. For example, 400 is used to represent a "Bad Request". You can also use an atom that corresponds to the status text for a given status code, e.g. :bad_request. (We'll see how that works in the plug_status section.)

The message argument represents the custom message we want to return.

NOTE: If you do not want to return a custom message, you can just pass in the plug_status argument when calling the exception. By default, when you raise any exception that has a plug_status argument, Phoenix will return a response, with a response body that consists of a plain string that corresponds to the status text of that particular status code (e.g. "Bad Request"). (This behaviour is defined your project's view-layer code, e.g. ErrorView for Phoenix <= v1.6, or ErrorJSON for Phoenix >= v1.7.)

plug_status

Each time we raise this exception, we will need to call it with the desired plug_status based on the HTTP status code we want to return. For example:

raise YourProject.PlugException, plug_status: 403

Instead of passing in an integer, you can also pass in an atom that corresponds to the HTTP status code:

raise YourProject.PlugException, plug_status: :forbidden

The result of this will be the exact same response as if we had used the integer 403 for our plug_status.

For a list of available status atoms and their codes, see this section of the Plug documentation. Note that the atom names correspond to the official values for those HTTP statuses.

NOTE: For more information on how specialized plug exceptions can be useful, check out the documentation for the Plug.Exception protocol.

message

The message argument is optional, but it allows us to provide helpful error messages (or other information) to the client.

raise YourProject.PlugException, plug_status: :forbidden, message: "No Bobs allowed"

By default, Phoenix will not do anything with this message, but we will add this functionality in the next section.

In our code, we assigned a default value of "" to the message argument. When we add the code in the next section, we will do a pattern match to ensure that the message has been set to a custom value. This ensures that our custom logic only runs when we explicitly call it. In all other circumstances, the other existing code will run instead. This ensures that our new logic fits gracefully with the existing logic.

NOTE: As mentioned previously, message doesn't have to be a string. It can be any JSON-serializable value (e.g. a map or a list).

Modify the View Layer Code

This code will work for both Phoenix <= v1.6 and Phoenix >= v1.7.

The only difference is that the code will go in a different module depending on which version of Phoenix you are using:

  • For Phoenix <= v1.6, add the code to the YourProject.ErrorView module.
  • For Phoenix >= v1.7, add the code to the YourProject.ErrorJSON module.

If you are using :phoenix_view in Phoenix >= v1.7, you can use example provided for the Phoenix <= v1.6 code.

Phoenix <= v1.6

lib/your_project_web/views/error_view.ex

defmodule YourProjectWeb.ErrorView do
  use YourProjectWeb, :view

  # If you want to customize a particular status code
  # for a certain format, you may uncomment below.
  # def render("500.html", _assigns) do
  #   "Internal Server Error"
  # end

+  @doc "Render a JSON response with custom message."
+  def render(
+        <<_status::binary-3>> <> ".json",
+        %{conn: %{assigns: %{reason: %YourProject.PlugException{message: message}}}}
+      )
+      when message != "" do
+    message
+  end

  # By default, Phoenix returns the status message from
  # the template name. For example, "404.html" becomes
  # "Not Found".
  def template_not_found(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end

Phoenix >= v1.7

lib/your_project_web/controllers/error_json.ex

defmodule YourProjectWeb.ErrorJSON do
  # If you want to customize a particular status code,
  # you may add your own clauses, such as:
  #
  # def render("500.json", _assigns) do
  #   %{errors: %{detail: "Internal Server Error"}}
  # end

+  @doc "Render a JSON response with custom message."
+  def render(
+        <<_status::binary-3>> <> ".json",
+        %{conn: %{assigns: %{reason: %YourProject.PlugException{message: message}}}}
+      )
+      when message != "" do
+    message
+  end

  # By default, Phoenix returns the status message from
  # the template name. For example, "404.json" becomes
  # "Not Found".
  def render(template, _assigns) do
    %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
  end
end

And that's all there is to it!

This new function matches on any string with 7 characters that ends in ".json", which will occur when Phoenix detects an exception with a valid plug_status argument. It also matches on an instance of our new exception with a non-empty message value, thus ensuring that this code will only run when we raise our new exception with a custom message value.

As we saw in the example code in the beginning of this tutorial, you can now add a line like this anywhere in your request-response lifecycle (e.g. in your controller code):

raise YourProject.PlugException, plug_status: 403, message: "No Bobs allowed."

Now, Phoenix will return a response with the desired HTTP status code and response body. As an example, here's a response to a request made with HTTPie:

$ http POST localhost:4000 name=Bob
HTTP/1.1 403 Forbidden
cache-control: max-age=0, private, must-revalidate
content-encoding: gzip
content-length: 38
content-type: application/json; charset=utf-8
date: Sun, 07 Apr 2024 22:40:53 GMT
vary: accept-encoding
x-request-id: F8QgpZebKnfbZb0AAAAN

"No Bobs allowed."
 

Conclusion

As we have seen, it is easy to add a feature to Phoenix that allows us to return custom HTTP responses at any point in the request-response lifecycle.

Phoenix may not have every bell and whistle, but I have always found that it makes it easy to integrate pretty much any feature I have needed.

I hope this tutorial has been helpful for you!


This blog post is licensed under CC-BY 4.0. Feel free to share and reuse the content as you please. Please provide attribution by linking back to this post.


Return to blog