Search code examples
asp.net-mvciiscache-controliis-10

How to specify max-age for error pages in ASP.NET MVC


When an error occurs on my ASP.NET MVC Server (running on IIS), the server currently serves a static page. This is configured in the httpErrors element in my web.config, like so:

<httpErrors errorMode="Custom" existingResponse="Replace">
  <error statusCode="404" path="404.htm" responseMode="File" />
  <error statusCode="500" path="500.htm" responseMode="File" />
</httpErrors>

When inspecting the response from the server, I see a cache-control: private response header. This is good, though I want to control how long this page is cached. How can I add max-age=x to this cache-control header?


Solution

  • If I understand your problem statement correctly your main goal was to have control over max-age, rather than fancy <customErrors> setup. It seems logical to try and control the header from an Action Filter.

    In web.config

    I've got this system.web setup:

    <system.web>
      <compilation debug="true" targetFramework="4.6.1"/> <!-- framework version for reference -->
      <httpRuntime targetFramework="4.6.1"/>
      <customErrors mode="On">
      </customErrors> <!-- I didn't try adding custom pages here, but this blog seem to have a solution: https://benfoster.io/blog/aspnet-mvc-custom-error-pages -->
    </system.web>
    

    In MaxAgeFilter.cs

    public class MaxAgeFilter : ActionFilterAttribute, IResultFilter, IExceptionFilter
    {
        public void OnException(ExceptionContext filterContext)
        {
            if (filterContext.ExceptionHandled || filterContext.HttpContext.IsCustomErrorEnabled)
                return;
    
            var statusCode = (int)HttpStatusCode.InternalServerError;
            if (filterContext.Exception is HttpException)
            {
                statusCode = (filterContext.Exception as HttpException).GetHttpCode();
            }
            else if (filterContext.Exception is UnauthorizedAccessException)
            {
                statusCode = (int)HttpStatusCode.Forbidden;
            }
    
            var result = CreateActionResult(filterContext, statusCode);
            filterContext.Result = result;
    
            // Prepare the response code.
            filterContext.ExceptionHandled = true;
            filterContext.HttpContext.Response.Clear();
            filterContext.HttpContext.Response.StatusCode = statusCode;
            filterContext.HttpContext.Response.TrySkipIisCustomErrors = true;
    
            var cache = filterContext.HttpContext.Response.Cache;            
            cache.SetMaxAge(TimeSpan.FromSeconds(10)); // this requires a lot of extra plumbing which I suspect is necessary because if you were to rely on default error response - the cache will get overriden, see original SO answer: https://stackoverflow.com/questions/8144695/asp-net-mvc-custom-handleerror-filter-specify-view-based-on-exception-type
        }
    
        public override void OnResultExecuted(ResultExecutedContext filterContext)
        {            
            var cache = filterContext.HttpContext.Response.Cache;         
            cache.SetMaxAge(TimeSpan.FromMinutes(10)); // this is easy - you just pass it to the current cache and magic works
            base.OnResultExecuted(filterContext);
        }
    
        protected virtual ActionResult CreateActionResult(ExceptionContext filterContext, int statusCode)
        {
            var ctx = new ControllerContext(filterContext.RequestContext, filterContext.Controller);
            var statusCodeName = ((HttpStatusCode)statusCode).ToString();
    
            var viewName = SelectFirstView(ctx,
                "~/Views/Shared/Error.cshtml",
                "~/Views/Shared/Error.cshtml",
                statusCodeName,
                "Error");
    
            var controllerName = (string)filterContext.RouteData.Values["controller"];
            var actionName = (string)filterContext.RouteData.Values["action"];
            var model = new HandleErrorInfo(filterContext.Exception, controllerName, actionName);
            var result = new ViewResult
            {
                ViewName = viewName,
                ViewData = new ViewDataDictionary<HandleErrorInfo>(model),
            };
            result.ViewBag.StatusCode = statusCode;
            return result;
        }
    
        protected string SelectFirstView(ControllerContext ctx, params string[] viewNames)
        {
            return viewNames.First(view => ViewExists(ctx, view));
        }
    
        protected bool ViewExists(ControllerContext ctx, string name)
        {
            var result = ViewEngines.Engines.FindView(ctx, name, null);
            return result.View != null;
        }
    }
    

    as you see, handling an exception basically requires rebuilding the whole Response. For this I pretty much took the code from this SO answer here

    Finally, you decide whether you want this attribute on your controllers, actions or set up globally:

    App_Start/FilterConfig.cs

    public class FilterConfig
    {
        public static void RegisterGlobalFilters(GlobalFilterCollection filters)
        {
            filters.Add(new MaxAgeFilter());
        }
    }