Handling Exceptions

Handling Exceptions

Standardmäßig rendert Rails im Produktionsmodus eine statische Fehlerdatei, wenn eine Ausnahme auftritt. Hier erfahren Sie, wie Sie dieses Verhalten vollständig anpassen können, um dynamische Fehlerseiten zu rendern.

Vorbereitung

Eine Rails-Anwendung zum Behandeln von Fehlern erstellen

In diesem Tutorial beschäftigen wir uns damit, wie Rails Ausnahmen behandelt und wie wir eigene Fehlerseiten für HTTP-Fehler wie 404 Not Found, 422 Unprocessable Content oder 500 Internal Server Error erstellen können.
Zunächst erstellen wir eine neue Rails-Anwendung:
$ rails new handling_exceptions
$ cd handling_exceptions
Für unsere Beispielanwendung benötigen wir außerdem eine einfache Product-Ressource:
$ rails g scaffold Product name price:decimal
Anschließend führen wir die erzeugte Migration aus:
$ rails db:migrate
Damit besitzen wir eine kleine Anwendung, an der wir unterschiedliche Fehlerfälle ausprobieren können.

Wie Rails Fehler behandelt

Während der Entwicklung zeigt Rails bei einer nicht behandelten Ausnahme normalerweise eine ausführliche Debug-Seite an.
Diese enthält Informationen, die uns bei der Fehlersuche helfen, beispielsweise die ausgelöste Exception, den Stacktrace, Request-Informationen und weitere Details zur Ursache des Fehlers.
In einer Produktionsumgebung sollen solche internen Informationen dagegen nicht an Besucher ausgegeben werden. Dort behandelt Rails Fehler anders und zeigt eine entsprechende Fehlerantwort beziehungsweise Fehlerseite an.
Um dieses Verhalten während der Entwicklung zu testen, können wir Rails anweisen, Requests nicht mehr als lokale Requests zu behandeln.
Dazu öffnen wir:
# config/environments/development.rb
und setzen:
config.consider_all_requests_local = false
Nach einer Änderung an der Environment-Konfiguration starten wir den Rails-Server neu:
$ bin/rails server
Lösen wir jetzt eine unbehandelte Ausnahme aus, sehen wir nicht mehr die ausführliche Rails-Debug-Seite, sondern das Verhalten, das wir für nicht lokale Requests konfiguriert haben.

Die Einstellung anschließend wieder zurücksetzen

Die ausführliche Debug-Seite ist während der normalen Entwicklung sehr hilfreich. Deshalb sollten wir die Einstellung nur zum Testen unseres Fehlerverhaltens ändern.
Nach dem Test stellen wir wieder den ursprünglichen Entwicklungswert her:
# config/environments/development.rb

config.consider_all_requests_local = true
Für die weiteren Tests unserer eigenen Fehlerseiten können wir die Einstellung bei Bedarf erneut vorübergehend auf false setzen.

Statische Fehlerseiten

Rails-Anwendungen besitzen außerdem statische Fehlerseiten im Verzeichnis:
public/
Dort befinden sich je nach Rails-Version beispielsweise Dateien für verschiedene HTTP-Fehler.
Diese statischen Seiten benötigen keinen Rails-Controller und keine View. Das macht sie als einfache Fallback-Seiten nützlich, weil für ihre Darstellung möglichst wenig Anwendungscode ausgeführt werden muss.
Bevor eine Anwendung produktiv eingesetzt wird, sollten diese Seiten deshalb zum Design der Anwendung passen und keine Standardseite zeigen, die optisch nichts mit der eigentlichen Website zu tun hat.
Im nächsten Schritt sehen wir uns an, wie Rails Exceptions bestimmten HTTP-Statuscodes zuordnet und wie wir für eigene Exceptions einen passenden Status definieren können.

1.webp 42.4 KB

Eigene Ausnahmen einem HTTP-Status zuordnen

Eine benutzerdefinierte Exception erstellen

Rails kennt bereits eine Reihe von Exceptions, denen automatisch bestimmte HTTP-Statuscodes zugeordnet werden.
Wird beispielsweise ein Datensatz mit find nicht gefunden, löst Active Record eine ActiveRecord::RecordNotFound-Exception aus. Rails behandelt diese normalerweise als:
404 Not Found
Eine unbekannte Route führt ebenfalls zu einer entsprechenden 404-Antwort.
Bei einer selbst definierten Exception kennt Rails den gewünschten HTTP-Status dagegen zunächst nicht.
Um das auszuprobieren, erstellen wir für unseren ProductsController eine eigene Exception namens Forbidden:
# app/controllers/products_controller.rb

class ProductsController < ApplicationController
  class Forbidden < StandardError; end

  # ...

  def show
    raise Forbidden, "You are not allowed to access this product."
  end

  # ...
end
Rufen wir jetzt die Show-Seite eines Produkts auf, beispielsweise:
/products/1
wird unsere eigene Exception ausgelöst.
raise Forbidden, "You are not allowed to access this product."
Da Rails zunächst nicht weiß, welchem HTTP-Status ProductsController::Forbidden entsprechen soll, wird die unbehandelte Exception standardmäßig als Serverfehler behandelt.
Für unsere Exception ist das jedoch nicht die gewünschte Bedeutung. Wir möchten ausdrücken, dass der Zugriff auf die Ressource verboten ist.
Der passende HTTP-Status ist:
403 Forbidden

Die Exception einem Statuscode zuordnen

Rails besitzt dafür die Konfiguration:
config.action_dispatch.rescue_responses
Wir ergänzen die Zuordnung in unserer Anwendungskonfiguration:
# config/application.rb

module HandlingExceptions
  class Application < Rails::Application
    # ...

    config.action_dispatch.rescue_responses["ProductsController::Forbidden"] = :forbidden
  end
end
Nach einer Änderung an config/application.rb starten wir den Rails-Server neu.
Rails weiß nun, dass:
ProductsController::Forbidden
dem HTTP-Status:
403 Forbidden
entspricht.
Das Symbol:
:forbidden
steht dabei für den HTTP-Statuscode 403.

Bereits vorhandene Zuordnungen in Rails

Dasselbe Prinzip verwendet Rails intern auch für viele seiner eigenen Exceptions.
Ein typisches Beispiel ist:
ActiveRecord::RecordNotFound
Diese Exception entsteht beispielsweise bei:
Product.find(999_999)
wenn kein entsprechender Datensatz vorhanden ist.
Rails ordnet diese Exception einer 404 Not Found-Antwort zu.
Dadurch müssen wir in unseren Controllern nicht bei jedem find selbst einen Fehlerstatus erzeugen.
Auch Routing-Fehler werden von Rails entsprechend behandelt. Wird beispielsweise eine URL aufgerufen, für die keine Route existiert, kann Rails daraus eine 404-Antwort erzeugen.

Statuscode und Fehlerseite sind zwei verschiedene Dinge

An dieser Stelle ist eine Unterscheidung wichtig.
Mit:
config.action_dispatch.rescue_responses["ProductsController::Forbidden"] = :forbidden
legen wir zunächst fest, welchen HTTP-Status unsere Exception repräsentiert.
Wir haben damit noch keine individuelle dynamische Fehlerseite erstellt.
Unsere Anwendung weiß jetzt lediglich:
ProductsController::Forbidden → 403 Forbidden
Wie die eigentliche Fehlerantwort dargestellt wird, behandeln wir anschließend separat.
Damit können wir eigene fachliche Exceptions definieren und trotzdem die normalen HTTP-Statuscodes von Rails verwenden.

Dynamische Fehlerseiten

Bestimmte Exceptions mit rescue_from behandeln

Bisher haben wir festgelegt, welchem HTTP-Status unsere eigene Exception entspricht. Manchmal möchten wir eine bestimmte Exception jedoch direkt innerhalb unserer Anwendung behandeln.
Rails stellt dafür in Controllern rescue_from zur Verfügung.
Wir können beispielsweise unsere zuvor erstellte Exception:
ProductsController::Forbidden
im ApplicationController abfangen:
# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  rescue_from ProductsController::Forbidden, with: :forbidden

  private

  def forbidden(exception)
    render plain: exception.message, status: :forbidden
  end
end
Unsere show-Action im ProductsController löst weiterhin die Exception aus:
# app/controllers/products_controller.rb

def show
  raise Forbidden, "You are not allowed to access this product."
end
Rufen wir nun die Show-Seite eines Produkts auf, wird:
ProductsController::Forbidden
ausgelöst.
rescue_from fängt diese Exception ab und ruft:
forbidden(exception)
auf.
In unserem einfachen Beispiel geben wir die Nachricht der Exception aus:
render plain: exception.message, status: :forbidden
Der Benutzer erhält dadurch die Meldung:
You are not allowed to access this product.
zusammen mit dem korrekten HTTP-Status:
403 Forbidden
Statt einfachen Text auszugeben, könnten wir an dieser Stelle auch eine eigene View rendern oder eine andere gezielte Reaktion auf diese Exception implementieren.

Wann rescue_from sinnvoll ist

rescue_from eignet sich gut, wenn wir bestimmte erwartete Exceptions innerhalb unserer Controller-Schicht gezielt behandeln möchten.
Wir könnten beispielsweise für eine bestimmte fachliche Exception eine eigene Fehlermeldung anzeigen:
rescue_from ProductsController::Forbidden, with: :forbidden
Damit legen wir ausdrücklich fest, wie unsere Anwendung auf genau diesen Fehler reagieren soll.
Für eine allgemeine Fehlerbehandlung der gesamten Rails-Anwendung ist dieser Ansatz jedoch weniger geeignet.

Nicht alle Exceptions mit rescue_from abfangen

Wir könnten versucht sein, eine sehr allgemeine Regel einzubauen.
Beispielsweise:
rescue_from Exception, with: :handle_exception
und anschließend:
def handle_exception(exception)
  render plain: exception.message
end
Eine solche globale Behandlung sollten wir nicht verwenden.
Exception ist in Ruby die Basisklasse für wesentlich mehr als die normalen Laufzeitfehler unserer Anwendung. Darunter befinden sich auch Exceptions, die nicht als gewöhnliche Anwendungsfehler behandelt werden sollten.
Selbst eine breit angelegte Behandlung von StandardError sollte nur sehr bewusst eingesetzt werden, da dadurch Fehler verborgen oder das normale Fehlerverhalten von Rails umgangen werden können.
Wir bleiben deshalb bei der gezielten Behandlung:
rescue_from ProductsController::Forbidden, with: :forbidden

rescue_from behandelt nicht jeden Fehler der Anwendung

Es gibt noch einen weiteren wichtigen Unterschied.
Ein rescue_from im ApplicationController kann nur Fehler behandeln, die den Controller-Verarbeitungsweg erreichen und dort entsprechend behandelt werden können.
Ein Fehler kann jedoch bereits entstehen, bevor überhaupt eine Controller-Aktion ausgeführt wird.
Ein typisches Beispiel ist eine URL, für die keine Route existiert.
Wenn wir beispielsweise:
/does-not-exist
aufrufen, kann Rails bereits beim Routing feststellen, dass keine passende Route vorhanden ist.
Unsere Controller-basierte Fehlerbehandlung ist deshalb nicht die richtige zentrale Stelle, um sämtliche Fehlerseiten der Anwendung bereitzustellen.
Für eine allgemeine Lösung müssen wir die Fehlerbehandlung auf einer anderen Ebene betrachten.
Rails besitzt dafür Middleware, die nicht behandelte Exceptions auffängt und an eine sogenannte exceptions_app weitergeben kann.
Genau diese Möglichkeit verwenden wir im nächsten Schritt, um zentrale dynamische Fehlerseiten für unsere Anwendung einzurichten.
Da unsere Forbidden-Exception anschließend von dieser zentralen Fehlerbehandlung verarbeitet werden soll, entfernen wir zuvor die zu Demonstrationszwecken eingerichtete rescue_from-Behandlung wieder aus dem ApplicationController.
Wir entfernen also:
rescue_from ProductsController::Forbidden, with: :forbidden
sowie die dazugehörige Methode:
private

def forbidden(exception)
  render plain: exception.message, status: :forbidden
end
Andere bereits vorhandene Einträge im ApplicationController bleiben unverändert.
Die Forbidden-Klasse im ProductsController und ihre Zuordnung zum HTTP-Status 403 in config/application.rb bleiben ebenfalls bestehen.
Damit kann die Exception im nächsten Schritt die zentrale Rails-Fehlerbehandlung durchlaufen.

Ausnahmen mit der Rails-Fehlerbehandlung behandeln

Eine eigene exceptions_app verwenden

rescue_from eignet sich gut, wenn wir bestimmte Exceptions innerhalb unserer Controller gezielt behandeln möchten. Für zentrale Fehlerseiten unserer gesamten Anwendung benötigen wir jedoch eine allgemeinere Lösung.
Rails verarbeitet nicht behandelte Exceptions über seine Middleware. Dabei kann Rails die Darstellung einer Fehlerantwort an eine sogenannte exceptions_app delegieren.
Standardmäßig können dabei unter anderem die statischen Fehlerseiten aus dem public-Verzeichnis verwendet werden.
Wir möchten stattdessen unsere normalen Rails-Routen verwenden, damit Fehler von einem eigenen Controller und eigenen Views verarbeitet werden können.
Dazu ergänzen wir in unserer Anwendungskonfiguration:
# config/application.rb

module HandlingExceptions
  class Application < Rails::Application
    # ...

    config.exceptions_app = routes
  end
end
Mit:
config.exceptions_app = routes
weisen wir Rails an, Fehler intern wieder über das Routing unserer Anwendung zu verarbeiten.
Eine 404-Fehlerantwort kann dadurch beispielsweise als Request auf:
/404
an unsere Routes übergeben werden.
Für einen internen Serverfehler entsprechend:
/500
Damit können wir diese Pfade genauso behandeln wie andere Routen unserer Anwendung.
Nach einer Änderung an config/application.rb starten wir den Rails-Server neu.

Einen Controller für Fehlerseiten erstellen

Für unsere dynamischen Fehlerseiten erstellen wir einen eigenen Controller:
$ rails g controller Errors
Der ErrorsController übernimmt später die Darstellung unserer verschiedenen Fehlerseiten.

Fehlerstatus über Routes weiterleiten

Jetzt definieren wir Routen für die HTTP-Statuscodes, die wir in unserer Anwendung behandeln möchten.
Wir beginnen mit vier typischen Fehlerfällen:
# config/routes.rb

Rails.application.routes.draw do
  resources :products

  root "products#index"

  get "403", to: "errors#forbidden"
  get "404", to: "errors#not_found"
  get "422", to: "errors#unprocessable_content"
  get "500", to: "errors#internal_server_error"

  get "up" => "rails/health#show", as: :rails_health_check
end
Damit besitzt jeder Statuscode eine eindeutige Controller-Aktion.
Wir verwenden:
403 → forbidden
404 → not_found
422 → unprocessable_content
500 → internal_server_error
Dadurch bleibt bereits anhand des Action-Namens erkennbar, welcher HTTP-Fehler behandelt wird.

Den ErrorsController einrichten

Nun implementieren wir die entsprechenden Actions:
# app/controllers/errors_controller.rb

class ErrorsController < ApplicationController
  before_action :load_exception

  layout "error"

  def forbidden
    render status: :forbidden
  end

  def not_found
    render status: :not_found
  end

  def unprocessable_content
    render status: :unprocessable_content
  end

  def internal_server_error
    render status: :internal_server_error
  end

  private

  def load_exception
    @exception = request.env["action_dispatch.exception"]
  end
end
Jede Action rendert ihre eigene View und liefert gleichzeitig den korrekten HTTP-Status zurück.
Beispielsweise:
render status: :not_found
liefert:
404 Not Found
und:
render status: :internal_server_error
liefert:
500 Internal Server Error
Das ist wichtig: Eine optisch dargestellte Fehlerseite allein reicht nicht aus. Auch der HTTP-Status der Response muss zum jeweiligen Fehler passen.

Auf die ursprüngliche Exception zugreifen

Rails stellt die ursprüngliche Exception über das Request-Environment zur Verfügung.
Wir speichern sie deshalb in:
@exception = request.env["action_dispatch.exception"]
Damit können wir später innerhalb unserer Fehler-Views auf Informationen zur ursprünglichen Exception zugreifen.
Beispielsweise:
<%= @exception.message %>
Dabei ist jedoch Vorsicht geboten.
Exception-Meldungen können interne Informationen über unsere Anwendung enthalten. Eine solche Meldung sollten wir deshalb nicht grundsätzlich ungefiltert auf einer öffentlichen Fehlerseite ausgeben.
Für eine benutzerdefinierte Exception mit einer bewusst formulierten Nachricht kann sie nützlich sein. Bei unerwarteten internen Fehlern sollte der Benutzer dagegen normalerweise eine allgemeine Fehlermeldung erhalten.

Warum wir die Statuscodes einzeln routen

Man könnte versuchen, alle dreistelligen Pfade mit einer einzigen dynamischen Route abzufangen.
Für unser Tutorial verwenden wir bewusst explizite Routen:
get "403", to: "errors#forbidden"
get "404", to: "errors#not_found"
get "422", to: "errors#unprocessable_content"
get "500", to: "errors#internal_server_error"
Damit können nur die Fehlerstatus aufgerufen werden, für die wir tatsächlich eine Fehlerseite eingerichtet haben.
Außerdem können wir für jeden Status eine eigene View und bei Bedarf eine eigene Behandlung definieren.
Unsere zentrale Fehlerbehandlung steht damit. Als Nächstes benötigen wir das gemeinsame Error-Layout und die eigentlichen Views für 403, 404, 422 und 500.

Eigene Fehlerseiten erstellen

Ein gemeinsames Layout für Fehlerseiten anlegen

Unser ErrorsController verwendet bereits:
layout "error"
Jetzt erstellen wir das dazugehörige Layout:
<!-- app/views/layouts/error.html.erb -->

<!DOCTYPE html>
<html>
  <head>
    <title>Handling Exceptions</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <main id="container">
      <%= yield %>
    </main>
  </body>
</html>
Durch:
<%= yield %>
wird die jeweilige Fehler-View innerhalb dieses Layouts ausgegeben.
Alle Fehlerseiten können dadurch dasselbe Grundlayout und das vorhandene Styling unserer Anwendung verwenden.

Eine Seite für 403 Forbidden erstellen

Unsere benutzerdefinierte ProductsController::Forbidden-Exception haben wir dem HTTP-Status 403 zugeordnet.
Dafür erstellen wir:
<!-- app/views/errors/forbidden.html.erb -->

<h1>Forbidden</h1>

<p>You are not authorized to perform that action.</p>
Der zugehörige Controller-Code lautet:
def forbidden
  render status: :forbidden
end
Damit erhält der Benutzer nicht nur unsere Fehlerseite, sondern die Response besitzt auch tatsächlich den HTTP-Status:
403 Forbidden

Eine Seite für 404 Not Found erstellen

Für nicht vorhandene Seiten erstellen wir:
<!-- app/views/errors/not_found.html.erb -->

<h1>Not Found</h1>

<p>The page you are looking for doesn't exist.</p>
Der ErrorsController rendert diese View mit:
def not_found
  render status: :not_found
end
Die Response erhält dadurch:
404 Not Found
Diese Seite kann beispielsweise verwendet werden, wenn eine nicht vorhandene Route oder ein nicht vorhandener Datensatz aufgerufen wird.

Eine Seite für 422 Unprocessable Content erstellen

Für einen Request, den der Server zwar versteht, aber nicht verarbeiten kann, erstellen wir:
<!-- app/views/errors/unprocessable_content.html.erb -->

<h1>Change Rejected</h1>

<p>The requested change could not be processed.</p>
Die entsprechende Action lautet:
def unprocessable_content
  render status: :unprocessable_content
end
Damit antwortet Rails mit dem Status 422.

Eine Seite für 500 Internal Server Error erstellen

Schließlich benötigen wir eine Fehlerseite für unerwartete interne Fehler:
<!-- app/views/errors/internal_server_error.html.erb -->

<h1>Something went wrong</h1>

<p>An unexpected error occurred. Please try again later.</p>
Die dazugehörige Action lautet:
def internal_server_error
  render status: :internal_server_error
end
Die Response besitzt damit den Status:
500 Internal Server Error
Gerade auf einer 500-Fehlerseite sollten wir Besuchern keine technischen Details der ursprünglichen Exception anzeigen.
Eine Meldung wie:
<%= @exception.message %>
kann beispielsweise interne Klassennamen, Datenbankinformationen oder andere Details über unsere Anwendung enthalten.
Für unerwartete Produktionsfehler verwenden wir deshalb eine allgemeine Meldung.

Die ursprüngliche Exception gezielt verwenden

Unser ErrorsController stellt weiterhin:
@exception = request.env["action_dispatch.exception"]
bereit.
Dadurch können wir die Exception bei Bedarf verwenden. Wir sollten ihre Meldung aber nur dann öffentlich ausgeben, wenn wir sicher sind, dass sie für Benutzer bestimmt ist.
Bei unserer bewusst definierten Forbidden-Exception können wir beispielsweise die von uns selbst formulierte Nachricht verwenden:
<!-- app/views/errors/forbidden.html.erb -->

<h1>Forbidden</h1>

<% if @exception.is_a?(ProductsController::Forbidden) %>
  <p><%= @exception.message %></p>
<% else %>
  <p>You are not authorized to perform that action.</p>
<% end %>
Unsere Exception wird im ProductsController mit einer kontrollierten Meldung ausgelöst:
raise Forbidden, "You are not allowed to access this product."
In diesem konkreten Fall wissen wir deshalb, dass der Text für die Ausgabe vorgesehen ist.
Bei einer beliebigen 500-Exception machen wir das ausdrücklich nicht.

Der vollständige ErrorsController

Unser Controller sieht damit momentan folgendermaßen aus:
# app/controllers/errors_controller.rb

class ErrorsController < ApplicationController
  before_action :load_exception

  layout "error"

  def forbidden
    render status: :forbidden
  end

  def not_found
    render status: :not_found
  end

  def unprocessable_content
    render status: :unprocessable_content
  end

  def internal_server_error
    render status: :internal_server_error
  end

  private

  def load_exception
    @exception = request.env["action_dispatch.exception"]
  end
end
Wir besitzen damit für jeden vorgesehenen Fehlerstatus eine eigene Route, eine eigene Controller-Action und eine eigene View.
Gleichzeitig verwenden alle Fehlerseiten dasselbe error-Layout.

2.webp 54.2 KB

Dynamische Fehlerseiten testen

Den Ablauf der Fehlerbehandlung verstehen

Unsere Anwendung besitzt jetzt eine zentrale Fehlerbehandlung. Wird eine Exception nicht bereits an anderer Stelle behandelt, kann Rails sie über unsere konfigurierte exceptions_app an die eigenen Routes weitergeben.
Wir haben dafür in:
# config/application.rb

config.exceptions_app = routes
festgelegt, dass die Rails-Routen für die Darstellung der Fehlerseiten zuständig sind.
Der Ablauf sieht vereinfacht so aus:
Exception
    ↓
Rails-Fehlerbehandlung
    ↓
exceptions_app
    ↓
/403, /404, /422 oder /500
    ↓
ErrorsController
    ↓
passende Fehler-View
Welcher Fehlerpfad verwendet wird, hängt vom HTTP-Status ab, den Rails der jeweiligen Exception zuordnet.

Unsere Forbidden-Exception testen

Im ProductsController haben wir unsere eigene Exception definiert:
# app/controllers/products_controller.rb

class ProductsController < ApplicationController
  class Forbidden < StandardError; end

  # ...

  def show
    raise Forbidden, "You are not allowed to access this product."
  end

  # ...
end
In config/application.rb haben wir Rails außerdem mitgeteilt, dass diese Exception einem 403 Forbidden entspricht:
config.action_dispatch.rescue_responses["ProductsController::Forbidden"] = :forbidden
Wird die Exception ausgelöst, kann Rails sie deshalb dem Status 403 zuordnen.
Unsere exceptions_app verarbeitet anschließend den Fehler über:
/403
und damit über:
get "403", to: "errors#forbidden"
Die Route ruft unsere forbidden-Action auf:
def forbidden
  render status: :forbidden
end
und Rails rendert:
app/views/errors/forbidden.html.erb
Da wir die ursprüngliche Exception über:
@exception = request.env["action_dispatch.exception"]
geladen haben, kann unsere View bei dieser bewusst definierten Exception auch deren Meldung anzeigen:
<!-- app/views/errors/forbidden.html.erb -->

<h1>Forbidden</h1>

<% if @exception.is_a?(ProductsController::Forbidden) %>
  <p><%= @exception.message %></p>
<% else %>
  <p>You are not authorized to perform that action.</p>
<% end %>
In unserem Beispiel erscheint damit:
You are not allowed to access this product.

Einen 404 Not Found testen

Als Nächstes können wir eine URL aufrufen, für die keine Route existiert, beispielsweise:
/this-page-does-not-exist
Rails behandelt den Routing-Fehler als 404 Not Found.
Über unsere exceptions_app wird daraus intern die entsprechende Fehlerroute:
/404
Diese führt zu:
get "404", to: "errors#not_found"
und damit zu unserer eigenen 404-View.
Dasselbe Prinzip kann auch bei anderen Exceptions greifen, die Rails dem Status 404 zuordnet, beispielsweise bei einem nicht gefundenen Active-Record-Datensatz.

Einen 500 Internal Server Error testen

Zum Testen einer unerwarteten Exception können wir vorübergehend in einer Controller-Action einen normalen Fehler auslösen:
raise "Test exception"
Diese Zeile dient ausschließlich zum Testen und wird anschließend wieder entfernt.
Ist der Request so konfiguriert, dass Rails seine normale Fehlerbehandlung verwendet, wird die unbehandelte Exception als 500 Internal Server Error behandelt.
Über unsere Fehlerroute:
get "500", to: "errors#internal_server_error"
wird anschließend unsere eigene 500-View gerendert.
Auf dieser Seite geben wir bewusst nicht aus:
<%= @exception.message %>
Stattdessen erhält der Benutzer eine allgemeine Meldung:
<h1>Something went wrong</h1>

<p>An unexpected error occurred. Please try again later.</p>
Interne Informationen zur Exception gehören in Logs beziehungsweise ein eingesetztes Error-Monitoring und nicht auf eine öffentliche 500-Seite.

Fehlerseiten direkt aufrufen

Während der Entwicklung können wir unsere Fehlerseiten außerdem direkt über ihre Routes überprüfen:
/403
/404
/422
/500
Das ist praktisch, wenn wir lediglich Layout und Inhalt der Fehlerseiten kontrollieren möchten, ohne jedes Mal eine echte Exception auszulösen.
Dabei testen wir allerdings nur die jeweilige Route, Controller-Action und View.
Um die komplette Exception-Behandlung zu testen, sollten wir zusätzlich einen echten Fehler auslösen. Nur dann prüfen wir auch den Weg über exceptions_app.

Fehlerbehandlung nur unter realistischen Bedingungen testen

Während der normalen Entwicklung zeigt Rails ausführliche Debug-Informationen an. Für einen realistischen Test unserer zentralen Fehlerseiten können wir deshalb vorübergehend:
# config/environments/development.rb

config.consider_all_requests_local = false
setzen.
Anschließend starten wir den Rails-Server neu und lösen den gewünschten Fehler aus.
Nach Abschluss unserer Tests setzen wir die Einstellung wieder zurück:
config.consider_all_requests_local = true
Damit steht uns bei der normalen Entwicklung wieder die ausführliche Rails-Debug-Seite zur Verfügung.

Testcode anschließend entfernen

Nachdem wir die Fehlerbehandlung erfolgreich getestet haben, entfernen wir auch die absichtlich ausgelösten Test-Exceptions wieder.
Insbesondere entfernen wir aus der show-Action des ProductsController diese Zeile:
raise Forbidden, "You are not allowed to access this product."
Die Klasse:
class Forbidden < StandardError; end
kann bestehen bleiben, wenn wir sie später für eine tatsächliche Zugriffsprüfung verwenden möchten. Das permanente raise diente lediglich dazu, die Fehlerbehandlung in diesem Tutorial zu demonstrieren.
Falls wir für den Test eines 500 Internal Server Error zusätzlich:
raise "Test exception"
eingefügt haben, entfernen wir auch diese Zeile wieder.
Damit befindet sich unsere Anwendung nach den Tests wieder in einem normalen ausführbaren Zustand.

3.webp 57.4 KB

Dynamische und statische Fehlerseiten

Dynamische Fehlerseiten als primäre Fehlerbehandlung verwenden

Unsere Anwendung verwendet jetzt eigene Rails-Routen für die Behandlung von Exceptions:
# config/application.rb

config.exceptions_app = routes
Dadurch können wir Fehlerseiten über den ErrorsController und normale Rails-Views erzeugen.
Unsere Fehler-Routen lauten:
# config/routes.rb

get "403", to: "errors#forbidden"
get "404", to: "errors#not_found"
get "422", to: "errors#unprocessable_content"
get "500", to: "errors#internal_server_error"
Damit können unsere Fehlerseiten dasselbe Layout und Stylesheet wie der Rest unserer Anwendung verwenden.
Im Gegensatz zu vollständig statischen HTML-Dateien können wir sie außerdem über Rails-Views verwalten und bei Bedarf dynamische Inhalte verwenden.

Statische Fehlerseiten als Fallback behalten

Rails-Anwendungen enthalten normalerweise zusätzlich statische Fehlerseiten im Verzeichnis:
public/
Je nach Rails-Version finden wir dort beispielsweise:
public/404.html
public/406-unsupported-browser.html
public/422.html
public/500.html
Diese Dateien müssen wir nicht allein deshalb löschen, weil wir dynamische Rails-Fehlerseiten eingerichtet haben.
Statische Fehlerseiten können weiterhin als unabhängige Fallback-Seiten sinnvoll sein, beispielsweise wenn sie direkt vom Webserver oder von einer vorgeschalteten Infrastruktur ausgeliefert werden.
Da diese Dateien vollständig statisch sind, benötigen sie weder einen Rails-Controller noch eine View oder eine funktionierende Datenbankverbindung.
Wir sollten allerdings nicht davon ausgehen, dass Rails bei einem Fehler innerhalb unserer eigenen dynamischen Fehlerbehandlung automatisch auf diese Dateien zurückfällt. Unsere dynamischen Fehlerseiten müssen deshalb selbst möglichst einfach und robust bleiben.
Auch die statischen Fehlerseiten sollten wir an das grundlegende Design unserer Anwendung anpassen. Dabei sollten sie möglichst einfach bleiben und keine Ressourcen benötigen, die wiederum von einer funktionierenden Rails-Anwendung abhängen.

Fehlerseiten selbst möglichst robust halten

Bei dynamischen Fehlerseiten sollten wir besonders darauf achten, keinen weiteren Fehler während der Fehlerbehandlung auszulösen.
Eine 500-Seite sollte deshalb beispielsweise nicht erst komplexe Daten aus der Datenbank laden.
Auch aufwendige Abhängigkeiten oder Anwendungslogik sollten wir dort möglichst vermeiden.
Unsere Fehlerseiten bestehen deshalb bewusst hauptsächlich aus einfachen Views.
Die 500-Seite enthält beispielsweise lediglich:
<!-- app/views/errors/internal_server_error.html.erb -->

<h1>Something went wrong</h1>

<p>An unexpected error occurred. Please try again later.</p>
Selbst wenn die ursprüngliche Exception in:
request.env["action_dispatch.exception"]
vorhanden ist, geben wir ihre Meldung bei einem unerwarteten internen Fehler nicht öffentlich aus.

Der endgültige Aufbau

Unsere dynamische Fehlerbehandlung besteht jetzt aus der Konfiguration:
# config/application.rb

config.action_dispatch.rescue_responses["ProductsController::Forbidden"] = :forbidden
config.exceptions_app = routes
den Fehler-Routen:
# config/routes.rb

get "403", to: "errors#forbidden"
get "404", to: "errors#not_found"
get "422", to: "errors#unprocessable_content"
get "500", to: "errors#internal_server_error"
dem Controller:
# app/controllers/errors_controller.rb

class ErrorsController < ApplicationController
  before_action :load_exception

  layout "error"

  def forbidden
    render status: :forbidden
  end

  def not_found
    render status: :not_found
  end

  def unprocessable_content
    render status: :unprocessable_content
  end

  def internal_server_error
    render status: :internal_server_error
  end

  private

  def load_exception
    @exception = request.env["action_dispatch.exception"]
  end
end
sowie unserem gemeinsamen Layout:
app/views/layouts/error.html.erb
und den vier Fehler-Views:
app/views/errors/forbidden.html.erb
app/views/errors/not_found.html.erb
app/views/errors/unprocessable_content.html.erb
app/views/errors/internal_server_error.html.erb
Damit haben wir zwei Ebenen der Fehlerdarstellung:
Dynamische Rails-Fehlerseiten
        ↓
ErrorsController + Views

Statische Fallback-Seiten
        ↓
public/*.html

Zusammenfassung

Rails kann Exceptions automatisch bestimmten HTTP-Statuscodes zuordnen. Für eigene Exceptions können wir diese Zuordnung über rescue_responses ergänzen.
Mit:
config.exceptions_app = routes
können wir die Darstellung von Fehlern an unsere eigenen Rails-Routen weitergeben.
Dadurch können 403, 404, 422 und 500 über einen gemeinsamen ErrorsController und eigene Views dargestellt werden.
rescue_from bleibt eine gute Möglichkeit, bestimmte erwartete Exceptions gezielt innerhalb der Controller-Schicht zu behandeln. Es sollte jedoch nicht dazu verwendet werden, pauschal jede mögliche Exception der gesamten Anwendung abzufangen.
Unsere statischen Fehlerseiten in public/ behalten wir zusätzlich als einfache Fallback-Seiten.
Damit besitzt unsere Anwendung eine zentrale, erweiterbare und gleichzeitig robuste Grundlage für die Behandlung und Darstellung von Fehlern.
Meld dich an und schreibe ein Kommentar