Avoiding server errors when generating thumbnails in Sitecore

,

We routinely monitor the logs of the websites we work on, and in the post I am going to describe one issue I recently located and fixed in a Sitecore 10.3 XM solution. The issue generated bursts of 500 server errors in the logs when a content editor accessed the Media Library via the Sitecore Content Editor.

The problem, which we first located via the logs, arises when Sitecore tries to generate thumbnails for a versioned image on a language in which the image has no version. This primarily happens when a editor browses the Media Library, but is not readily apparent from the UI.

After some testing, I managed to recreate the issue on a clean Sitecore 10.3. To explain the problem, lets look at this simple content tree:

As you can see, I have created three images using the build-in /sitecore/templates/System/Media/Versioned/Image template, which allow the image to be language specific. In this case, I have created the images on English:

However, if I change the language, and then access the media library I will get broken links for the thumbnails used multiple places in the Sitecore UI:

The problem here is not the missing thumbnail in the right side of the screen – Sitecore should not show a thumbnail for a versioned image on a non-existing language. The problem is the way the image request fails, which become apparent when the network traffic is inspected:

As you can see, the server returns a 500 status code caused by an unhandled exception on the server. Depending on exactly how the editor access and browser the media library, substantial amounts of 500 errors can be generated in a short time as the editor browser through the Media Library. This can affect the performance of the application negatively.

The error in the log is somewhat vague, but allowed us to locate the problem in the MediaRequestHandler:

<pre style="font-size: 70% !important;">
9032 20:09:06 ERROR Application error.
Exception: System.ObjectDisposedException
Message: Cannot access a closed file.
Source: mscorlib
   at System.IO.__Error.FileNotOpen()
   at System.IO.FileStream.get_Length()
   at Sitecore.Resources.Media.Streaming.RangeRetrievalResponse.WriteFullContent(HttpContext context, Stream fileContent)
   at Sitecore.Resources.Media.MediaRequestHandler.DoProcessRequest(HttpContext context, MediaRequest request, Media media)
   at Sitecore.Resources.Media.MediaRequestHandler.ProcessRequest(HttpContext context)
   at System.Web.HttpApplication.CallHandlerExecutionStep.System.Web.HttpApplication.IExecutionStep.Execute()
   at System.Web.HttpApplication.ExecuteStepImpl(IExecutionStep step)
   at System.Web.HttpApplication.ExecuteStep(IExecutionStep step, Boolean& completedSynchronously)
</pre>

The solution

To get rid of the exceptions the first step is to patch the MediaRequestHandler, which unfortunately require changing the Web.config. We do this via a XDT – which is approach I would recommend (read more here):

<configuration  xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">
    <system.webServer>
        <handlers>
            <add xdt:Transform="Replace"
                xdt:Locator="Match(name)"
                verb="*"
                path="sitecore_media.ashx"
                type="sc10xm.Handlers.CustomMediaRequestHandler, sc10xm"
                name="Sitecore.MediaRequestHandler">
            </add>
        </handlers>
    </system.webServer>
</configuration>

However exactly how to patch the handler is really up to us, and how much of Sitecore’s code we wish to duplicate into your own solution. I will sketch out three solutions below, which will serve to highlight some of the consideration we should have when patching Sitecore’s code.

Solution 1

The entry point in the handler is the DoProcessRequest(HttpContext context) method which does all the expected checks (does the media exist and does the particular user have access to it). But curiously enough, thumbnail requests are exempt from some of these checks, and this is why the exception is “allowed” to happen (see line 67 below where I have kept Sitecore’s default logic).

So overwriting this method allow us to patch the logic at the point where it “belongs”: and where Sitecore’s standard logic allows the error to happen:

namespace sc10xm.Handlers
{
    using Sitecore;
    using Sitecore.Configuration;
    using Sitecore.Diagnostics;
    using Sitecore.Globalization;
    using Sitecore.Resources;
    using Sitecore.Resources.Media;
    using Sitecore.SecurityModel;
    using Sitecore.Text;
    using System.Web;

    public class CustomMediaRequestHandler : MediaRequestHandler
    {
        protected override bool DoProcessRequest(HttpContext context)
        {
            Assert.ArgumentNotNull(context, "context");
            MediaRequest mediaRequest = GetMediaRequest(context.Request);
            if (mediaRequest == null)
            {
                return false;
            }

            string redirectUrl = null;
            Media media = MediaManager.GetMedia(mediaRequest.MediaUri);
            if (media == null)
            {
                using (new SecurityDisabler())
                {
                    media = MediaManager.GetMedia(mediaRequest.MediaUri);
                }

                if (media == null)
                {
                    redirectUrl = Settings.ItemNotFoundUrl;
                }
                else
                {
                    Assert.IsNotNull(Context.Site, "site");
                    if (!Context.User.IsAuthenticated && Context.Site.RequireLogin && 
                        !string.IsNullOrEmpty(Context.Site.LoginPage))
                    {
                        redirectUrl = Context.Site.LoginPage;
                        if (Settings.Authentication.SaveRawUrl)
                        {
                            UrlString urlString = new UrlString(redirectUrl);
                            urlString.Append("url", HttpUtility.UrlEncode(Context.RawUrl));
                            redirectUrl = urlString.GetUrl();
                        }
                    }
                    else
                    {
                        redirectUrl = Settings.NoAccessUrl;
                    }
                }
            }
            else
            {
                // Custom code: Handle thumbnails without thumbnails
                if (mediaRequest.Options.Thumbnail && !media.MediaData.HasContent)
                {
                    redirectUrl = Themes.MapTheme(Settings.DefaultIcon).ToLowerInvariant();
                }
                else
                {
                    // Standard Siteore logic: 
                    bool flag = mediaRequest.Options.Thumbnail || media.MediaData.HasContent;
                    string path = media.MediaData.MediaItem.InnerItem["path"].ToLowerInvariant();
                    if (!flag && !string.IsNullOrEmpty(path))
                    {
                        MediaUri mediaUri = new MediaUri(path,
                            Language.Current,
                            Sitecore.Data.Version.Latest,
                            Context.Database);
                        Media media2 = MediaManager.GetMedia(mediaUri);
                        if (media2 != null)
                        {
                            media = media2;
                        }
                    }
                    else if (mediaRequest.Options.UseDefaultIcon && !flag)
                    {
                        redirectUrl = Themes.MapTheme(Settings.DefaultIcon).ToLowerInvariant();
                    }
                    else if (!mediaRequest.Options.UseDefaultIcon && !flag)
                    {
                        redirectUrl = Settings.ItemNotFoundUrl;
                    }
                }
            }

            if (!string.IsNullOrEmpty(redirectUrl))
            {
                HttpContext.Current.Response.Redirect(redirectUrl);
                return true;
            }

            return DoProcessRequest(context, mediaRequest, media);
        }
    }
}

Notice how the standard Sitecore logic exempts thumbnail requests for the HasContent() check, allowing media requests without content to pass through the checks and end up in DoProcessRequest(context, mediaRequest, media), where the exception occurs.

One could argue that this in regards to the overall flow of the code is the correct approach, but the downside is that we have to include a large part of the MediaRequestHandler code in our solution, which will give problems if we later want to e.g. upgrade Sitecore.

Solution 2

Hence another approach is to move the custom logic into the DoProcessRequest(context, mediaRequest, media) method:

namespace sc10xm.Handlers
{
    using Sitecore.Configuration;
    using Sitecore.Resources;
    using Sitecore.Resources.Media;
    using System.Web;

    public class CustomMediaRequestHandler : MediaRequestHandler
    {
        protected override bool DoProcessRequest(
            HttpContext context, 
            MediaRequest request, 
            Media media)
        {
            // Custom code: Handle thumbnails without content
            if (request.Options.Thumbnail && !media.MediaData.HasContent)
            {
                HttpContext.Current.Response.Redirect(
                    Themes.MapTheme(Settings.DefaultIcon).ToLowerInvariant());
                return true;
            }

            return base.DoProcessRequest(context, request, media);
        }
    }
}

This approach allow us to greatly reduce the amount of Sitecore code in our solution, which is always a good approach.

Solution 3

Finally there is also the option to let the exception happens, but catch the exception:

namespace sc10xm.Handlers
{
    using Sitecore.Configuration;
    using Sitecore.Resources;
    using Sitecore.Resources.Media;
    using System.Web;

    public class CustomMediaRequestHandler : MediaRequestHandler
    {
        protected override bool DoProcessRequest(
            HttpContext context, 
            MediaRequest request, 
            Media media)
        {
            try
            {
                return base.DoProcessRequest(context, request, media);
            }
            catch
            {
                if (request.Options.Thumbnail && !media.MediaData.HasContent)
                {
                    HttpContext.Current.Response.Redirect(
                        Themes.MapTheme(Settings.DefaultIcon).ToLowerInvariant());
                    return true;
                }

                throw;
            }
        }
    }
}

The idea behind this approach is that we have not fully understood the default Sitecore logic that exempt thumbnails from the HasContent() check in line 67. Potentially there could be situations where do want to generate thumbnails even for images without content, and where the DoProcessRequest(context, mediaRequest, media) method will not throw an exception. This approach will “give it a go” and only catch the exception after it happens. There is a small performance impact of throwing and catching exceptions (but nothing compared to throwing and not catching exceptions), but the benefit of this approach is that we allow Sitecore’s default logic to execute, and only intervene if it fails.

Final note

Whichever approach we use (and we ended up using solution 3), the result is the same: The default icon is now returned a thumbnail for a non-existing versioned image are requested, and the bursts of 500 errors is gone.