IIS and ASP.NET custom fout pagina's, hoe te kiezen

19 feb. 2017

Als .NET web developer heb ik me regelmatig bezig gehouden met fout afhandeling voor websites. Meestal was dat geen makkelijk proces. Er zijn gewoon teveel opties en teveel manieren om de gebruiker te laten weten dat er iets mis ging. Nieuwe (sommige niet zo nieuw meer) technieken zoals MVC.NET, IIS integrated mode en .NET CORE voegde nog meer mogelijkheden toe. Er kan veel gezegd worden over hoe om te gaan met het afvangen en loggen van fouten in code maar ik wil hier alleen focussen op de verschillende mogelijkheden van het tonen van fout informatie naar de gebruiker via custom fout pagina's.

Wat is een goede fout pagina

Een goede fout pagina moet alle voor de gebruiker benodigde informatie tonen over wat er fout ging en indien mogelijk hoe het probleem te verhelpen is. Met benodigde informatie bedoel ik precies genoeg informatie voor de gemiddelde gebruiker om te begrijpen wat er fout ging, dus geen technische details als SQL query xxx on database yyyy failed due to a primary key violation. Geen enkele gebruiker heeft iets aan zulke detail informatie (behalve als de gebruiker de ontwikkelaar van de website is maar als je daarvoor ontwikkeld heb je sowieso een probleem). Een betere melding zou iets kunnen zijn als Uw account kon niet aangemaakt worden. Probeer het later nogmaals. Als het probleem zich blijft voordoen neem dan contact met ons op via xxx-yyy. Afgezien van het feit dat gebruikers geen behoefte hebben aan technische details kan het tonen van teveel details ook een veiligheidsrisico zijn. Weglaten die details dus.

Het is ook raadzaam om waar mogelijk de gebruiker opties aan te bieden om hem verder op weg te helpen. Als er bijvoorbeeld een Internal Server Error (code 500) optreedt die veroorzaakt wordt door een timeout zou je de gebruiker kunnen informeren dat het probleem waarschijnlijk tijdelijk is en dat hij het over een paar minuten nogmaals kan proberen. Een ander voorbeeld is een Page Not Found (code 404) fout, hierbij kan je de gebruiker op weg helpen door alternatieve links aan te bieden naar de informatie die hij waarschijnlijk zocht (dit kan je soms achterhalen door de opgevraagde URL te bekijken). Als je niet kan bepalen wat de gebruiker zocht kan je altijd een link toevoegen naar je zoekpagina (of plaats een zoekveld op je 404 pagina) of naar de eerste bestaande pagina een niveau hoger in de opgevraagde URL (voor een 404 op de link /nieuws/beurs-crash/ zou je naar de /nieuws/ link kunnen verwijzen). Als je je gebruikers echt in alle gevallen zou goed mogelijk support wil bieden dan voeg je op je fout pagina's opties toe om contact met je op te nemen via email, telefoon of chat. Eventueel kan je nog een unieke code toevoegen aan je fout pagina's die gebruikers kunnen doorgeven aan je helpdesk als ze daar contact mee opnemen. Met deze code zou de helpdesk dan in de logs de bijbehorende technische details kunnen opzoeken. Zo ziet de gebruiker geen technische details maar heeft de helpdesk medewerker wel alle details beschikbaar.

Opties om een custom fout pagina te tonen

Nu we hebben vastgesteld wat een fout pagina moet bevatten moeten we onze applicatie vertellen hoe en wanneer welke fout pagina getoond moet worden. In ASP.NET web applicaties (.NET Core laat ik voor nu even buiten beschouwing) heb je grofweg de volgende opties:

  1. De customErrors sectie in de web.config
  2. De httpErrors sectie in de web.config
  3. Routing naar een fout pagina vanuit code

De customErrors sectie in de web.config

De customErrors sectie in de web.config bestaat al heel lang in ASP.NET. Het is onderdeel van de system.web sectie en vangt unhandled exceptions in .NET applicaties af en stuurt requests waarin de exceptions optreden door naar de juiste fout pagina's. Je kan per status code van de error (alle codes vanaf 400) een fout pagina opgeven. Een gemiddelde customErrors sectie ziet er ongeveer als volgt uit:

<customErrors mode="RemoteOnly" redirectMode="ResponseRewrite" defaultRedirect="~/error.aspx">
      <error statusCode="500" redirect="~/500.aspx" />
      <error statusCode="400" redirect="~/400.aspx" />
      <error statusCode="404" redirect="~/404.aspx" />
</customErrors>

mode attribuut

Het mode attribuut kan de volgende waardes hebben:

  • Off
  • On
  • RemoteOnly

Off schakelt het tonen van custom fout pagina's volledig uit, in het geval van fouten worden de standaard gedetailleerde ASP.NET fout pagina's getoond. Bij On worden altijd custom fout pagina's getoond. RemoteOnly toont alleen custom fout pagina's voor requests die niet afkomstig zijn van de server waarop de website draait, voor requests vanaf de server zelf worden de gedetailleerde ASP.NET fout pagina's getoond. Dit is de standaard en aangeraden instelling hoewel je voor het testen van de fout pagina's in je ontwikkel omgeving de On moet gebruiken.

redirectMode attribuut

Dit attribuut kan maar 2 waardes hebben:

  • Redirect
  • ResponseRewrite

De waarde Redirect doet precies wat je verwacht en redirects de URL waarbij de fout optreed naar de URL die je opgeeft bij het redirect attribuut van het error element. Ik raad sterk af deze waarde te gebruiken. De redirect maakt het niet duidelijker voor de gebruiker omdat de URL verandert en het is ook geen goed idee vanuit SEO perspectief om te redirecten naar een fout pagina. De betere en gebruikelijke optie is om hier ResponseRewrite te gebruiken, hierbij blijft de URL ongewijzigd en wordt de opgegeven fout pagina gerendered als response van het huidige request. Deze optie heeft wel een nadeel. De response geeft een code 200 terug terwijl een fout pagina altijd de juiste fout status code moet teruggeven. De makkelijkste oplossing hiervoor is om een .ASPX pagina te gebruiken als fout pagina (ja, dus ook binnen een MVC applicatie) en daarin handmatig de status code van de response in te stellen.

<% Response.StatusCode = 500; %>

defaultRedirect attribuut

Met dit attribuut kan je een standaard pagina aangeven voor alle fouten waarvoor niet expliciet een pagina opgegeven is. Als je bijvoorbeeld voor fouten met status code 500 en 404 een fout pagina's opgeven hebt maar er ontstaat een fout met code 401 dan zou de pagina gebruikt worden die je bij dit defaultRedirect attribuut opgegeven hebt. Het is aan te raden dit attribuut altijd te gebruiken.

De httpErrors sectie in de web.config

Sinds de introductie van Integrated Mode in IIS 7 is er een nieuwe optie bijgekomen om fout pagina's te tonen. Met de httpErrors sectie (onder de system.webServer sectie) kan je fout pagina's configureren op IIS server niveau. Het belangrijkste verschil met customErrors is het feit dat httpErrors fouten kan afhandelen voor alle requests, niet alleen voor requests die door ASP.NET afgehandeld worden. Dit betekent dat als er bijvoorbeeld iets mis gaat bij het opvragen van een statische file (zoals een afbeelding) die volledig door IIS wordt afgehandeld zonder tussenkomst van ASP.NET code, dat hiervoor ook een custom fout pagina getoond kan worden. Een gemiddelde configuratie voor httpErrors ziet er als volgt uit:

<httpErrors errorMode="Custom" existingResponse="Auto" defaultResponseMode="ExecuteURL" defaultPath="/error.aspx">
      <clear />
      <error statusCode="500" path="/500.aspx" />
      <error statusCode="404" path="/404.aspx" />
</httpErrors>

Met de httpErrors sectie kan je net als bij customErrors per status code een fout pagina toewijzen. Het is aan te raden om eerst alle fout pagina's te verwijderen die op hogere niveaus in config files gedefinieerd zijn, dit wordt in het voorbeeld gedaan met <clear />. De belangrijkste attributen van het httpErrors root element zijn:

errorMode attribuut

Dit attribuut is vergelijkbaar met het mode attribuut van customErrors. Het kan de volgende 3 waardes hebben, Custom, Detailed en DetailedLocalOnly. Normaal kies je hier voor Custom of voor DetailedLocalOnly, de eerste optie toont altijd je eigen fout pagina's, de tweede alleen wanneer het request van een buiten de server afkomstig is.

existingResponse attribuut

Als een response een fout status code heeft (>= 400) dan kan httpErrors iets met deze response doen. ExistingResponse bepaald of en wanneer er iets met deze responses wordt gedaan. Er zijn weer 3 opties, Auto, PassThrough en Replace. PassThrough laat de response onveranderd door, Replace toont altijd een fout pagina (de standaard IIS versie of een eigen opgegeven). De Auto optie maakt een keuze tussen PassThrough en Replace afhankelijk van de waarde van een variabele die SetStatus heet. Deze variable is vanuit .NET in te stellen met de TrySkipIisCustomErrors eigenschap van het HttpResponse object. Als je true meegeeft zullen er geen fout pagina's door httpErrors getoond worden, bij false wel. Later meer hierover.

defaultResponseMode attribuut

Hiermee geef je aan hoe de request naar de fout pagina doorverwezen wordt. Je hebt weer 3 opties, File, ExecuteUrl en Redirect. The File optie haalt de inhoud van de fout pagina op en toont deze in de response zonder enige verwerking. ExecuteUrl roept de opgegeven relatieve URL aan en toont het resultaat. Met deze optie kan je .ASPX files als fout pagina gebruiken. Redirect doet precies wat het zegt en redirect de response naar de opgegeven URL. Het gebruik van deze optie is niet aan te raden onder andere uit SEO perspectief.

defaultPath attribuut

Dit attribuut laat je het pad opgeven waarnaar alle responses moeten worden verwezen die een fout status code hebben maar waarvoor niet expliciet een pad of file is opgegeven. Dit werkt identiek aan het defaultRedirect attribuut van customErrors.

error element

Het error element heeft slechts 2 verplichte attributen, statusCode en path, deze spreken redelijk voor zich. Let er wel op dat path afhankelijke van de instelling van defaultResponseMode een relatief bestands pad of een (relatieve) URL moet zijn. Eventueel kan je ook de response mode per error element instellen met het responseMode attribuut in plaats van alleen via defaultResponseMode in de httpErrors sectie.

Hoe httpErrors fouten afvangt

Een van de belangrijkste verschillen tussen httpErrors en customErrors is dat customErrors daadwerkelijk (.NET) exceptions afvangt terwijl httpErrors reageert op status codes in de response. Dit verschil heeft mij in het verleden flink wat hoofdbrekens bezorgt toen ik aan een site werkte die recent was over gegaan van IIS classic naar integrated mode. Deze overgang activeerde de httpErrors functionaliteit en hier hadden we niet goed rekening mee gehouden. Er traden hierdoor problemen op met sommige handlers op de site. Veel handlers waren ontworpen om bij fouten handmatig een 500 status code toe te voegen aan de response en JSON terug te geven met gegevens over de opgetreden fout. HttpErrors ving deze responses af en gaf HTML fout pagina's terug in plaats van de oorspronkelijke JSON. Het koste me enige tijd om hier achter te komen en vervolgens een oplossing te vinden.

De standaard instelling voor het httpErrors attribuut existingResponse is Auto wat betekend dat alle reponses met een fout status code in principe vervangen worden door een fout pagina van httpErrors. Behalve als de TrySkipIisCustomErrors eigenschap van het Response object in ASP.NET op true wordt gezet, in dat geval blijft de oorspronkelijke response zoals hij was. In ons geval moesten we in alle handlers die een 500 status code terug konden geven deze eigenschap op true zetten. Een andere oplossing was geweest om het existingResponse attribuut van httpErrors op PassThrough te zetten zodat geen enkele response met een status code van 500 vervangen werd door een httpErrors fout pagina. Dit was voor onze site echter niet wenselijk.

Omzeilen van customErrors

HttpErrors zou een goede vervanging van customErrors kunnen zijn, het vangt alle errors af, niet alleen degene in .NET code. Als je echter het mode attribuut van customErrors op Off zet met het doel customErrors uit te zetten dan zal in het geval van een fout een gedetailleerde ASP.NET fout pagina getoond worden en niet een httpErrors fout pagina. CustomErrors lijkt TrySkipIisCustomErrors altijd op true te zetten waardoor dus httpErrors fout pagina's nooit getoond worden, althans zolang daar existingResponse op de standaard stand Auto staat. De snelste oplossing is om existingResponse op Replace te zetten. Vaak is dit een goede oplossing maar dit kan dus problemen geven voor responses die een status code 500 terug geven in combinatie met een custom response body zoals handlers die een JSON result terug geven. In dit geval moet je existingResponse op Auto laten staan en je Global.asax zo aanpassen dat in het geval van een exception deze (na eventuele logging) ge-cleared wordt en de response status code op 500 wordt gezet. Omdat er nu wel een status code 500 in de response zit maar geen exception meer, zal customErrors niks doe met de response maar httpErrors wel.

void Application_Error(object sender, EventArgs e)
{
    Exception error = Server.GetLastError();

    // TODO : Log Exception

    Server.ClearError();

    var httpException = error as HttpException;
    Response.StatusCode = httpException != null ? httpException.GetHttpCode() : (int)HttpStatusCode.InternalServerError;
}

3. Routing naar een fout pagina vanuit code

Naast de 2 opties om responses bij fouten naar een fout pagina door te sturen met behulp van web.config configuratie zijn er meerdere mogelijkheden om hetzelfde te bereiken vanuit code. Het gaat hier te ver om deze opties in detail uit te leggen daarom een kort overzicht.

MVC Error attributen

Als je gebruik maakt van MVC.NET kan je het HandleError attribuut toevoegen aan alle Actions waarvoor je custom fout pagina's wil tonen.

[HandleError()]
public ActionResult Index()
{
}

Bij fouten wordt standaard de View Error.cshtml getoond (als deze bestaat natuurlijk). Je kan eventueel een andere View opgeven door de View parameter van HandleError te gebruiken, bijvoorbeeld HandleError(View = "500.cshtml"). HandleError werkt alleen als customErrors niet op mode="Off" staat in de web.config.

Overriding OnException in een MVC controller

Nog een optie voor MVC.NET gebruikers. Voor individuele Controllers of voor een BaseController, als je die gebruikt, kan je de OnException overriden.

protected override void OnException(ExceptionContext filterContext)
{
    filterContext.ExceptionHandled = true;
             
    filterContext.Result = new ViewResult
    {
        ViewName = "~/Views/Error/500.cshtml"
    };
}

Het Application_Error event

Deze optie werkt zowel voor MVC als Webforms. Je kan dit Event toevoegen aan je Global.asax, alle exceptions die niet ergens eerder in code zijn afgevangen komen hier terecht.

protected void Application_Error(object sender, EventArgs e)
{
    var raisedException = Server.GetLastError();

    // TODO : Log Exception

    Server.ClearError();

    Server.Transfer("~/500.aspx");
}

Een HttpModule

Een goede en herbruikbare optie is om een HttpModule te bouwen die exceptions afvangt, deze kan je vervolgens via de web.config aan al je web projecten toevoegen. Veel 3rd party error afhandel extensies (zoals Elmah) gebruiken deze techniek.

public class ErrorModule : IHttpModule
{
    public void Init(HttpApplication context)
    {
        context.Error += OnError;
    }

    private void OnError(object sender, EventArgs e)
    {
        // Handle error here, log, redirect or alter response
    }

    public void Dispose()
    {
        // TODO : Dispose
    }
}

Conclusie

Van alle technieken die hier besproken zijn lijkt httpErrors de beste papieren te hebben. Het werkt voor alle requests, ook voor bijvoorbeeld statische bestanden. CustomErrors heeft eigenlijk geen enkele meerwaarde meer boven httpErrors. De MVC error handle opties zijn makkelijk in gebruik maar kunnen alleen fouten in Controllers afhandelen, niet in andere code (voordat de Controller aangeroepen wordt) en ook niet voor statische bestanden. De Global.asax en HttpModules zijn goede alternatieven omdat ze alle fouten afvangen in alle code van de hele applicatie. Maar ze kunnen nog steeds geen fouten buiten de .NET code afvangen hoewel je eventueel HttpModules zo zou kunnen configureren dat alle requests (dus ook voor statische bestanden) afgevangen worden. Dit is zeker een optie maar simpele configuratie van httpErrors blijft toch de makkelijkste optie. Het is een makkelijk onderhoudbare optie om op een enkele plek je fout pagina's in te stellen. Bovendien zit je niet met dubbele pagina's die je moet onderhouden (bijvoorbeeld een 404 pagina voor customErrors en een tweede 404 pagina voor IIS statische files voor httpErrors). Je moet wel met een aantal dingen rekening houden als je httpErrors inzet, het omzeilen van customErrors, mogelijke issues met non-HTML responses (zoals JSON handlers) bijvoorbeeld. En vergeet ook niet dat fout pagina's in veel gevallen sowieso overbodig zijn. Er zijn nog steeds veel sites die bij foutieve gebruikers invoer een volledige 500 of 400 fout pagina terug geven. In dit soort situaties kan in bijna alle gevallen beter een nette melding op de pagina zelf getoond worden, dat helpt de gebruiker beter op weg. Voor echte fout pagina's geldt vooral dat je de gebruiker precies genoeg informatie moet geven om hem verder te helpen (Probeer het over 5 minuten nog eens, Misschien helpen de volgende links je verder..., Als het probleem zich blijft voordoen, neem dan contact met ons op via ...). Het is meestal een uitdaging om hiervoor de juiste teksten te bedenken maar uiteindelijk zullen gebruikers je meer waarderen als je goede fout pagina's hebt en het aantal calls naar de helpdesk zou er ook door kunnen verminderen. Fouten zijn slecht maar als ze zich toch voordoen zorg dan dat ze zo duidelijk en behulpzaam mogelijke informatie weergeven.