If an ASP.NET webservice errors in a forest and there is no-one around to hear it, does it make a sound?

Posted by Tom on 2009-08-28 21:38

Apparently not.

Try it yourself. Hook Application.OnError and write a webservice that throws an error.

It's cool. I'll wait.

Back? Splendid.

This was fine when no-one did anything particularly important with AJAX. Your AJAX fails and the user has to type in the whole address by themself? It's not ideal, but it shouldn't cause anyone to clear their basket in frustration and vow never to use the website again. However, if that same person now can't add anything to the basket then they don't have to clear their basket. You know, what with it already being empty and stuff.

Of course, other people have come across this charming idiosyncracy of .NET's web services, and it's pretty pitiful that however hard you shaft your application, as long as you do it inside a web service you'll never hear about it.

We've got a few options here as to how to proceed:

  1. Storm the Bastille. Personally I'm all for a march on Redmond, but I realise that practical considerations may make this unfeasible.
  2. Wrap every web service in a try catch block.
  3. Check the response code on Request.End. We'll still lose the error, since GetLastError never gets filled.
  4. Use a response filter, as laid out here by Daniel Richardson. At least with this we can parse the the response and grab the stack trace directly out of it.

If you have an existing error handling module then stuff this in. Failing that, putting it in Global.asax will work just as well.

public void Init(HttpApplication context)
{
    context.PostRequestHandlerExecute += new EventHandler(context_PostRequestHandlerExecute);
}
 
void context_PostRequestHandlerExecute(object sender, EventArgs e)
{
    HttpContext context = HttpContext.Current;
    if (context.Request.FilePath.Contains(".asmx") && context.Response.StatusCode == 500)
    {
        Stream foo = context.Response.Filter;
        if (context.Response.ContentType == "application/json")
        {
            context.Response.Filter = new JsonErrorResponseFilter(context.Response.OutputStream);
        }
    }
}

The documentation can be found at the usual places, but what this does is places an event handler which catches the response on the way out, checks to see if the request is for a web service, if it's in JSON format (so we don't mess with any ye olde SOAP requests), and traps anything with with a 500 response code (or 'Server Error' to you and me).

Line 11 may raise some eyebrows, but there's is a method to the madness. There is a property lazy-loading fail in the HttpContext.Response.Filter property. It's one of those charming 'works fine when I debug it and step through but dies in it's arse every other time' bugs you encounter every now and then (hai2u Commerce Server!)

The ResponseFilter itself inherits from Stream and doesn't do a whole lot. Most of it is interface boilerplate.

class JsonErrorResponseFilter : Stream
{
    private Stream _responseStream = null;
 
    public JsonErrorResponseFilter(Stream responseStream)
    {
        _responseStream = responseStream;
    }
 
    public override bool CanRead
    {
        get { return true; }
    }
 
    public override bool CanSeek
    {
        get { return true; }
    }
 
    public override bool CanWrite
    {
        get { return true; }
    }
 
    public override void Flush()
    {
        _responseStream.Flush();
    }
 
    public override long Length
    {
        get { return _responseStream.Length; }
    }
 
    public override long Position
    {
        get { return _responseStream.Position; }
        set { _responseStream.Position = value; }
    }
 
    public override int Read(byte[] buffer, int offset, int count)
    {
        return _responseStream.Read(buffer, offset, count);
    }
 
    public override long Seek(long offset, SeekOrigin origin)
    {
        return _responseStream.Seek(offset, origin);
    }
 
    public override void SetLength(long value)
    {
        _responseStream.SetLength(value);
    }
 
    public override void Write(byte[] buffer, int offset, int count)
    {
        string response = Encoding.UTF8.GetString(buffer, offset, count);
 
        JsonException error = new JsonException(response);
        Colourblind.Core.Log.Instance.Write(error);
 
        _responseStream.Write(buffer, offset, count);
    }
}

Well, now we have a copy of the outgoing response it's up to us what we want to do with it. I chose to create a new exception and pass that to my logging code, but you can print it out on the nearest Laserjet or skywrite it if you want.

Super-shiny-turbo-tip: the JavaScriptSerializer is available for your parsing needs.

public class JsonException : Exception
{
    private string _stackTrace;
    private string _message;
 
    public override string StackTrace
    {
        get { return _stackTrace; }
    }
 
    public override string Message
    {
        get { return _message; }
    }
 
    public JsonException(string json)
    {
        JavaScriptSerializer serialiser = new JavaScriptSerializer();
        Dictionary<string, string> data = serialiser.Deserialize<Dictionary<string, string>>(json);
 
        _message = data["Message"];
        _stackTrace = data["StackTrace"];
        Data.Add("OriginalExceptionType", data["ExceptionType"]);
    }
}

I think that about covers everything. If anyone has a better way or any improvements to the code then please let me know. Although I'll probably claim your work as my own and then start plotting to kill so that my secret never comes to light. Just to warn you.

As an aside, if your web service is returning SOAP then you'll need another method. Best bet is probably to use a SOAP Extension to override the SOAP pipeline. As recommended here by that Atwood fellah and covered in more details here (scroll down to 'Unhandled Exceptions in ASP.NET Web Services').