MVC: Action Filter for Handling Errors

April 2, 2008 12:29 AM

A few months ago I posted an article and some code that contained filters for forms authentication and error handling for the Preview 1 (CTP) release of the MVC framework. Unfortunately the Preview 2 release that was made available a few weeks ago changed enough that the code I posted no longer works.

What the Preview 2 release did provide, however, was a new built-in filter framework. Rob Conery has already gone through the trouble of creating authentication filters that cover most of the functionality I had before, but I have yet to see an implementation of a filter for error handling that I like. I've gone ahead and started from scratch, throwing away my old filters, and created a new filter that I think covers most of the same scenarios as my old ErrorHandler filters while being much simpler to implement and use. Hopefully you'll find it useful.

First let's take a look at a simple use case scenario:

   1: public void Product( int? id )
   2: {
   3:     if( id == null )
   4:         throw new ArgumentNullException( "No Product ID" );
   5:     RenderView( "DisplayProduct", GetProduct(id.Value) );
   6: }

In the code above, we have a simple action that displays a product based upon the ID specified. What do we do when no ID is specified though? The "correct" thing to do seems to be to throw an exception, as we've done, but now the user will see either (a) an ugly 500 error screen [worst case] or (b) be redirected to the generic error page [best case]. Sometimes we'd like a bit more control than that though...

Let's go ahead and add our error handling filter to this action and tell it that whenever ArgumentNullException is thrown, redirect to the "Products" page, where the user can select a product with a valid ID.

   1: [RedirectToUrlOnError(Type=typeof(ArgumentNullException),Url="/Products")]
   2: public void Product( int? id )
   3: {
   4:     if( id == null )
   5:         throw new ArgumentNullException( "No Product ID" );
   6:     RenderView( "DisplayProduct", GetProduct(id.Value) );
   7: }

So we've added a [RedirectToUrlOnError] attribute and supplied it with a Type property - detailing the exception to catch - and a Url property - specifying the Url to navigate to upon a matched exception. You'll notice we are making a call to the GetProduct(int) method to retrieve the product's model so that we can pass it into the view's ViewData. What if this method were to fail? What if we weren't entirely certain what exception it would throw, or maybe we didn't care, we just want to handle any exception except for ArgumentNullException (which is already being handled). In this case we'll add another filter, but this time we will not specify the Type of exception that it should catch and just tell it that if anything isn't caught by another error handler redirect to the homepage.

   1: [RedirectToUrlOnError(Type=typeof(ArgumentNullException),Url="/Products")]
   2: [RedirectToUrlOnError(Url="/")]
   3: public void Product( int? id )
   4: {
   5:     if( id == null )
   6:         throw new ArgumentNullException( "No Product ID" );
   7:     RenderView( "DisplayProduct", GetProduct(id.Value) );
   8: }

You can have as many error handler filters attached to an action as you need, but only one may have no Type specified.

Now let's take a look at the code for the filter itself:

   1: using System;
   2: using System.Collections.Generic;
   3: using System.Linq;
   4: using System.Web.Mvc;
   5:  
   6: namespace SquaredRoot.Mvc.Filters.ErrorHandling
   7: {
   8:     public class RedirectToUrlOnErrorAttribute : RedirectOnErrorAttribute
   9:     {
  10:  
  11:         public string Url{ get; set; }
  12:  
  13:         protected override bool Validate( FilterExecutedContext filterContext )
  14:         {
  15:  
  16:             //### the url property is always needed
  17:             if( string.IsNullOrEmpty( Url ) || Url.Trim() == string.Empty )
  18:                 throw new ArgumentNullException( "RedirectToUrlOnErrorAttribute's Url property must have a value." );
  19:  
  20:             //### continue execution
  21:             return true;
  22:  
  23:         }
  24:  
  25:         protected override void Redirect( FilterExecutedContext filterContext )
  26:         {
  27:             filterContext.ExceptionHandled = true;
  28:             filterContext.HttpContext.Response.Redirect( Url, true );
  29:         }
  30:  
  31:     }
  32: }

So the [RedirectToUrlOnError] attribute inherits from the [RedirectOnError] attribute, which is where most of the hard work is done. We'll take a look at that base class in a bit, but first let's look at the other attribute you can use to trap and respond to errors - the [RedirectToActionOnError] attribute. We'll continue with the Product(id) sample from above, but this time redirect to an action rather than a hardcoded Url:

   1: [RedirectToActionOnError(
   2:     Type=typeof(ArgumentNullException),
   3:     Controller=typeof(ProductController),
   4:     Action="Index" )]
   5: [RedirectToUrlOnError(Url="/")]
   6: public void Product( int? id )
   7: {
   8:     if( id == null )
   9:         throw new ArgumentNullException( "No Product ID" );
  10:     RenderView( "DisplayProduct", GetProduct(id.Value) );
  11: }

You can see that this time instead of providing the Url property we are using providing the type of the controller that contains our target action, and the name of the action as a string. (Unfortunately lambda expressions are not allowed as parameters to an attribute, so I was limited in my options here. If you have a better idea, please let me know!) Also note that the catch-all is still there as a [RedirectToUrlOnError] attribute. You may use the [RedirectToActionOnError] attribute as a catch-all and you can mix and match the two attribute types, but still only one catch-all attribute total is allowed per action (in other words, you cannot have one of each).

Now let's see the code for this filter:

   1: using System;
   2: using System.Collections.Generic;
   3: using System.Linq;
   4: using System.Web.Mvc;
   5: using System.Web.Routing;
   6:  
   7: namespace SquaredRoot.Mvc.Filters.ErrorHandling
   8: {
   9:     public class RedirectToActionOnErrorAttribute : RedirectOnErrorAttribute
  10:     {
  11:  
  12:         public Type Controller{ get; set; }
  13:         public string Action{ get; set; }
  14:  
  15:         protected override bool Validate( FilterExecutedContext filterContext )
  16:         {
  17:  
  18:             //### the url property is always needed
  19:             if(
  20:                 Controller == null ||
  21:                 ( string.IsNullOrEmpty( Action ) || Action.Trim() == string.Empty )
  22:             )
  23:                 throw new ArgumentNullException( "RedirectToUrlOnActionAttribute's Controller and Action properties must have values." );
  24:  
  25:             //### make sure the Contoller property is a Controller
  26:             if( !typeof(System.Web.Mvc.Controller).IsAssignableFrom( Controller ) )
  27:                 throw new ArgumentException( "RedirectToUrlOnActionAttribute's Controller property's value must derive from System.Web.Mvc.Controller." );
  28:  
  29:             //### continue processing
  30:             return true;
  31:  
  32:         }
  33:  
  34:         protected override void Redirect( FilterExecutedContext filterContext )
  35:         {
  36:  
  37:             //### turn "Foo.Foo.Foo.BarController" into "Bar"
  38:             string controllerName = Controller.ToString();
  39:             controllerName = controllerName.Substring( controllerName.LastIndexOf(".") + 1 );
  40:             controllerName = controllerName.Substring( 0, controllerName.LastIndexOf("Controller") );
  41:  
  42:             //### turn route data into url
  43:             RouteValueDictionary rvd = new RouteValueDictionary( new{
  44:                 controller = controllerName,
  45:                 action = Action
  46:             } );
  47:             ControllerContext ctx = new ControllerContext(
  48:                 filterContext.HttpContext,
  49:                 filterContext.RouteData,
  50:                 filterContext.Controller
  51:             );
  52:             VirtualPathData vpd = RouteTable.Routes.GetVirtualPath( ctx, rvd );
  53:             string url = vpd.VirtualPath;
  54:  
  55:             //### redirect
  56:             filterContext.ExceptionHandled = true;
  57:             filterContext.HttpContext.Response.Redirect( url, true );
  58:  
  59:         }
  60:  
  61:     }
  62: }

Other than some complexity with determining the Url, everything is very similar to the other filter. Again it appears the base class is doing the heavy lifting. Let's finally take a look at that base class:

   1: using System;
   2: using System.Collections.Generic;
   3: using System.Linq;
   4: using System.Web.Mvc;
   5:  
   6: namespace SquaredRoot.Mvc.Filters.ErrorHandling
   7: {
   8:     public abstract class RedirectOnErrorAttribute : ActionFilterAttribute
   9:     {
  10:  
  11:         public Type Type { get; set; }
  12:  
  13:         public override void OnActionExecuted( FilterExecutedContext filterContext )
  14:         {
  15:  
  16:             //### check for errors
  17:             if( !Validate(filterContext) )
  18:                 return;
  19:  
  20:             //### make sure the Type property is an Exception
  21:             if( Type != null && !typeof(System.Exception).IsAssignableFrom( Type ) )
  22:                 throw new ArgumentException( "RedirectOnErrorAttribute's Type property's value must derive from System.Exception." );
  23:  
  24:             //### if no exception occurred, stop processing this filter
  25:             if( filterContext.Exception == null )
  26:                 return;
  27:  
  28:             //### get inner exception unless it is null (this should never happen?)
  29:             Exception ex = filterContext.Exception.InnerException ?? filterContext.Exception;
  30:  
  31:             //### if exception was thrown because of Response.Redirect, ignore it
  32:             if( ex.GetType() == typeof(System.Threading.ThreadAbortException) )
  33:                 return;
  34:             else if( Type == typeof(System.Threading.ThreadAbortException) )
  35:                 throw new ArgumentException( "Cannot catch exceptions of type 'ThreadAbortException'." );
  36:  
  37:             //### if the specified Type matches the thrown exception, process it
  38:             if( IsExactMatch(ex) )
  39:                 Redirect( filterContext );
  40:             //### if this attribute has no specified Type, investigate further (this attribute is a catch-all error handler)
  41:             else if( Type == null )
  42:             {
  43:  
  44:                 //### loop through all other RedirectToUrlOnErrorAttribute on this method
  45:                 foreach( RedirectOnErrorAttribute att in GetAllAttributes( filterContext ) )
  46:                     //### ignore self
  47:                     if( att.GetHashCode() == this.GetHashCode() )
  48:                         continue;
  49:                     //### if another catch-all attribute is found, throw an exception
  50:                     else if( att.Type == null )
  51:                         throw new ArgumentException( "Only one RedirectOnErrorAttribute per Action may be specified without its Type property provided." );
  52:                     //### if an exact match is found, stop processing the catch-all. that attribute has priority
  53:                     else if( att.IsExactMatch(ex) )
  54:                         return;
  55:  
  56:                 //### no exact matches were found. if the specified Type for the catch-all fits, process here
  57:                 Redirect(filterContext);
  58:  
  59:             }
  60:             else
  61:                 //### specified Type was not null, but did not match the thrown exception. don't process
  62:                 return;
  63:  
  64:         }
  65:  
  66:         public bool IsExactMatch( Exception exception )
  67:         {
  68:             if( Type != null && exception.GetType() == Type )
  69:                 return true;
  70:             else
  71:                 return false;
  72:         }
  73:  
  74:         private List<RedirectOnErrorAttribute> GetAllAttributes( FilterExecutedContext filterContext )
  75:         {
  76:             return filterContext.ActionMethod
  77:                 .GetCustomAttributes( typeof( RedirectOnErrorAttribute ), false )
  78:                 .Select( a => a as RedirectOnErrorAttribute )
  79:                 .ToList();
  80:         }
  81:  
  82:         protected abstract bool Validate( FilterExecutedContext filterContext );
  83:         protected abstract void Redirect( FilterExecutedContext filterContext );
  84:  
  85:     }
  86: }

That's all there is. Feel free to take it, use it, change it, whatever. I tried my best to document it thoroughly with comments, but if you have any questions just drop a comment below and I'll try to respond quickly. I do ask that if you make any improvements, please leave a comment here letting everyone know what you've changed so that we can all benefit.

Here are the filters in a downloadable format:

RedirectOnErrorAttributes.zip (2.63 kb)

Comments

4/2/2008 5:08 AM #

Andy

Many thanks for your continued clear and helpful MVC blog posts. Cheers.

Andy gb

4/2/2008 6:03 AM #

Maarten Balliauw

Great code! I was thinking of doing something similar, but the fun thing about Internet is that someone's always a step ahead. Thanks for sharing this!

Maarten Balliauw be

4/2/2008 9:22 AM #

Nicolas Cadilhac

Thanks for this reusable code. Just one question: would it be possible to pass a RouteValueDictionary instead of a raw url?

Nicolas Cadilhac ca

4/2/2008 9:35 AM #

Troy Goode

Hi Nicolas,

You've read my mind. =)

You'll note that I called this filter Redirect-ToUrl-OnError. I'm currently exploring the available options to create a Redirect-ToAction-OnError filter, but wanted to go ahead and put this filter out there for everyone since this is what I have working so far.

I'll keep you posted on my progress.

Troy Goode us

4/2/2008 9:38 AM #

Nicolas Cadilhac

Logical! Your second filter will be a killer too.

Nicolas Cadilhac ca

4/2/2008 9:50 AM #

Nicolas Cadilhac

However I wonder how it would be possible to pass a key of the current action to the redirected action. Let's say you are in the action Update(int? id) and in case of error you want to be redirected to the previous page which corresponds to Edit(int? id). How would the id be passed back to Edit?

Nicolas Cadilhac ca

4/2/2008 1:47 PM #

Troy Goode

I'm not sure that will be possible, which is part of what is making it tricky. I'm trying to get the attribute to take a lambda func that points to an Action, but I currently have no ideas for how you could provide values without doing something very hacky (references to TempData values, for instance). In all likelyhood the first version of the next filter will only allow you to forward to an action that expects no parameters (or you'll need to hardcode the values that are passed)...

Troy Goode us

4/2/2008 4:55 PM #

Troy Goode

Okay, Nicolas, I've added a RedirectToAction filter (and updated the post with the code and download), but unfortunately:

(a) lambdas cannot be used as parameters to attributes
(b) I see no good way to pass a parameter to these filters without either hardcoding the data or doing something terribly messy

So, for now, this is probably all she wrote for my error handling filters. If you come up with any good ideas on how to proceed, drop me a line as always!

Troy Goode us

4/2/2008 7:33 PM #

tgmdbm

You can just reuse the current RouteData.

var rvd = new RouteValueDictionary( filterContext.RouteData.Values )

rvd["controller"] = controllerName
rvd["action"] = Action

however, this needs to be an optional behaviour.

tgmdbm gb

4/2/2008 7:38 PM #

Nicolas Cadilhac

Thank you Troy. But your knowledge goes far beyond mine so I'll trust you.

Nicolas Cadilhac ca

4/2/2008 10:14 PM #

Troy Goode

Nice suggestion James! I think that takes care of 90% of the use cases for Nicolas' idea. Well, at least 90% of the cases I've thought up over the last 30 seconds. Wink

I'll work on updating the code in the post tomorrow. Thanks again.

Troy Goode us

4/3/2008 5:55 PM #

Nicolas Cadilhac

Thank you James, and again for you Troy. I have now something working gently here.

Nicolas Cadilhac ca

4/8/2008 7:39 PM #

Troy Goode

Thought I'd let you guys know that I finally got around to implementing James suggestion. The code is available in the Mvc Membership Starter Kit's Codeplex project.

You can download it from here if you're interested (download from the Source Control tab, the current release -- 1.0 -- doesn't have it):
http://www.codeplex.com/MvcMembership

Troy Goode us

6/7/2008 5:25 PM #

Krzysztof

Hi
Nice soft Smile
I have one question, how can i pass Exception message to error page?

Krzysztof pl

6/7/2008 7:46 PM #

Troy Goode

Hi Krzysztof,

To do that I would recommend changing the code I have posted to store the exception into the Session using a known key (which you can then use to retrieve and delete it from the error page).

Please keep in mind that the code on this page is not current and may not work with Preview 3. For the latest version of the ErrorHandling filter's, please check out my Mvc Membership Starter Kit project:

http://www.codeplex.com/MvcMembership

Hopefully I'll find time to post an updated version of these filters to this blog sometime in the near future.

Troy Goode us

6/20/2008 11:37 AM #

Joshua McKinney

Have you considered submitting these to mvccontrib

Joshua McKinney au

7/1/2008 10:59 AM #

Troy Goode

@Josh:
No I hadn't really, primarily because they are derivative of work originally done by Rob Conery. Maybe I'll shoot Rob an email and see if he would be okay with me doing so.

Troy Goode us

Add Comment


(Will show your Gravatar icon)  

  Country flag

biuquote
  • Comment
  • Preview
Loading




Troy Goode

Troy Goode
Microsoft Certified Professional Developer
AddThis Feed Button

Disclaimer

The opinions expressed herein are my own personal opinions and do not represent my employer's view in  anyway.

© Copyright 2008

Colophon

Powered by:
BlogEngine.NET 1.4
Template:
Designs by Darren
Header Font:
Stamper
Syntax Highlighting:
WLW Code Snippet Plugin