Catching errors from calling ASP.NET WebMethod with malformed Json
Asked Answered
P

6

18

We have an older ASP.NET WebForms application which performs AJAX request by using jQuery $.ajax() calls on the client side, calling static methods in the page code-behind decorated with [WebMethod] attributes.

If an unhandled exception occurs within the WebMethod, it does not fire the Application_Error event and is thus not picked up by our error logger (ELMAH). This is well known and not a problem - we have all WebMethod code wrapped in try-catch blocks with exceptions being manually logged to ELMAH.

However, there is one case that has me stumped. If malformed Json is posted to the WebMethod URL, it throws an exception before entering our code, and I can't find any way to trap this.

e.g. this WebMethod signature

[WebMethod]
public static string LeWebMethod(string stringParam, int intParam)

Normally called with a Json payload like:

{"stringParam":"oh hai","intParam":37}

I tried a test using Fiddler to edit the payload to the malformed Json:

{"stringParam":"oh hai","intPara

And got the following ArgumentException error response from JavaScriptObjectDeserializer sent to the client (this is in a simple test app running locally with no custom errors):

{"Message":"Unterminated string passed in. (32): {\"stringParam\":\"oh hai\",\"intPara","StackTrace":"   at
System.Web.Script.Serialization.JavaScriptObjectDeserializer.DeserializeString()\r\n   at
System.Web.Script.Serialization.JavaScriptObjectDeserializer.DeserializeMemberName()\r\n   at
System.Web.Script.Serialization.JavaScriptObjectDeserializer.DeserializeDictionary(Int32 depth)\r\n   at 
System.Web.Script.Serialization.JavaScriptObjectDeserializer.DeserializeInternal(Int32 depth)\r\n   at 
System.Web.Script.Serialization.JavaScriptObjectDeserializer.BasicDeserialize(String input, Int32 depthLimit, JavaScriptSerializer serializer)\r\n   at 
System.Web.Script.Serialization.JavaScriptSerializer.Deserialize(JavaScriptSerializer serializer, String input, Type type, Int32 depthLimit)\r\n   at 
System.Web.Script.Serialization.JavaScriptSerializer.Deserialize[T](String input)\r\n   at 
System.Web.Script.Services.RestHandler.GetRawParamsFromPostRequest(HttpContext context, JavaScriptSerializer serializer)\r\n   at 
System.Web.Script.Services.RestHandler.GetRawParams(WebServiceMethodData methodData, HttpContext context)\r\n   at 
System.Web.Script.Services.RestHandler.ExecuteWebServiceCall(HttpContext context, WebServiceMethodData methodData)","ExceptionType":"System.ArgumentException"}

It's still not firing the Application_Error event, and it never enters our code so we can't log the error ourselves.

I found a similar question which got a pointer to the blog post "How to create a global exception handler for a Web Service" but that appears to only be valid for SOAP webservices, not AJAX GETs/POSTs.

Is there some similar way to attach a custom handler in my situation?

Podagra answered 5/6, 2014 at 7:51 Comment(0)
S
25

According to the reference source, the internal RestHandler.ExecuteWebServiceCall method catches all exceptions thrown by GetRawParams and simply writes them to the response stream, which is why Application_Error isn't invoked:

internal static void ExecuteWebServiceCall(HttpContext context, WebServiceMethodData methodData) {
    try {
        ...
        IDictionary<string, object> rawParams = GetRawParams(methodData, context);
        InvokeMethod(context, methodData, rawParams);
    }
    catch (Exception ex) {
        WriteExceptionJsonString(context, ex);
    }
}

The only workaround I can think of is to create an output filter that intercepts and logs the output:

public class PageMethodExceptionLogger : Stream
{
    private readonly HttpResponse _response;
    private readonly Stream _baseStream;
    private readonly MemoryStream _capturedStream = new MemoryStream();

    public PageMethodExceptionLogger(HttpResponse response)
    {
        _response = response;
        _baseStream = response.Filter;
    }

    public override void Close()
    {
        if (_response.StatusCode == 500 && _response.Headers["jsonerror"] == "true")
        {
            _capturedStream.Position = 0;
            string responseJson = new StreamReader(_capturedStream).ReadToEnd();
            // TODO: Do the actual logging.
        }

        _baseStream.Close();
        base.Close();
    }

    public override void Flush()
    {
        _baseStream.Flush();
    }

    public override long Seek(long offset, SeekOrigin origin)
    {
        return _baseStream.Seek(offset, origin);
    }

    public override void SetLength(long value)
    {
        _baseStream.SetLength(value);
    }

    public override int Read(byte[] buffer, int offset, int count)
    {
        return _baseStream.Read(buffer, offset, count);
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        _baseStream.Write(buffer, offset, count);
        _capturedStream.Write(buffer, offset, count);
    }

    public override bool CanRead { get { return _baseStream.CanRead; } }
    public override bool CanSeek { get { return _baseStream.CanSeek; } }
    public override bool CanWrite { get { return _baseStream.CanWrite; } }
    public override long Length { get { return _baseStream.Length; } }

    public override long Position
    {
        get { return _baseStream.Position; }
        set { _baseStream.Position = value; }
    }
}

In Global.asax.cs (or in an HTTP module), install the filter in Application_PostMapRequestHandler:

protected void Application_PostMapRequestHandler(object sender, EventArgs e)
{
    HttpContext context = HttpContext.Current;
    if (context.Handler is Page && !string.IsNullOrEmpty(context.Request.PathInfo))
    {
        string contentType = context.Request.ContentType.Split(';')[0];
        if (contentType.Equals("application/json", StringComparison.OrdinalIgnoreCase))
        {
            context.Response.Filter = new PageMethodExceptionLogger(context.Response);
        }
    }
}
Solana answered 14/6, 2014 at 16:59 Comment(14)
That looks like a very promising idea, I'll definitely try an experiment along these lines!Podagra
Michael that suggestion worked absolutely perfectly, it's a gem! I am so sorry that your answer arrived to late for me to award it the original bounty, I have created a fresh one to award to you. Apparently I have to wait 24 hours to award it, though.Podagra
@Carson63000: I'm glad my code works for you. And it's very generous of you to award a new bounty, but given its size, I would wait to see if someone else can contribute a better answer.Solana
Mate nobody is going to provide a better answer than one which analyzes the framework reference source and then provides complete code for a solution. Literally all I needed to do was wire up our log4net logger at your "TODO:" line. :-)Podagra
Some things are not clear to me, though it helped me so much. Thanks a lot.Scilicet
If we are using this for a thrown exception from a WebMethod, do we have access to the Exception so we can log it? I have not found that yet.Chiquita
@DanCsharpster: No. As you can see from the ExecuteWebServiceCall source code, the thrown Exception is written to the response stream as a string (which you can read using the responseJson variable in my code), and there's no hook to access the actual Exception object.Solana
@MichaelLiu, thanks! That's what I figured since it seems that this is essentially dealing with the response from the webmethod, so unless I started embedded the exception in the response object, there's no way this section of code would have access to the Exception. I tried the SoapExtension, but I believe the [System.Web.Script.Services.ScriptService] tag is keeping it from getting called on all of the exceptions I care about. So at the moment, I'm looking down the barrel of wrapped 300+ webmethods in try catches as a temporary solution, which is unfortunate.Chiquita
Thanks for your help! I was a little slow to figure it out. I wound up recreating an exception object to feed to the API to which I have to send the exception. My code looks like so: string responseJson = new StreamReader(_capturedStream).ReadToEnd(); dynamic exceptionObject = JObject.Parse(responseJson); var appException = new LoggableException( (exceptionObject.StackTrace != null ? exceptionObject.StackTrace.ToString() : String.Empty), ... );Chiquita
This works great in a "Debug" build, but when I switch to "Release" I just get a blank error. Any idea what I am doing wrong?Stardom
@mike: I don't know. Try posting a new question.Solana
@MichaelLiu - good idea! #57580884Stardom
ARGH! it just needed <system.web><customErrors mode="Off"/></system.web> in web config. Silly me.Stardom
This is working really well. But I can't find the parameters passed to the [WebMethod] I ran with your code, and passed the HttpContext object to the PageMethodExceptionLogger instead, which made it so I could log a lot more things about the error, but I just can't find the WebMethod Parameters. If anyone has any suggestions, i posted a revised version here: stackoverflow.com/questions/57778294 I could really use help.Stardom
C
1

When you say that you have static methods on the page code-behind marked with WebMethod and you say that you use $.ajax, that sounds just wrong. But I'll give the benefit of the doubt, as I don't know the particularities of you system.

Anyway, please test this:

  • You should have a ScriptManager on your page looking like this: (**1)

  • Then in that place where you have your $.ajax call, call you Page Method like this: (**2)

(**1)

<asp:ScriptManager ID="smPageManager"
        runat="server"
        EnablePageMethods="true" 
        ScriptMode="Release" 
        LoadScriptsBeforeUI="true"> 
</asp:ScriptManager>

(**2)

PageMethods.LeWebMethod("hero", 1024, function(response){
    alert(response);
}, function(error){
    alert(error);
});

Know using ASP.NET Ajax Library the proper way, give it a test, and see if the error reports back to you properly.

P.S: Sorry for the bookmark style notation, but SO, seems be experiencing some malfunction right now.

UPDATE

Reading this post, seems to explain the problem you are facing:

(...) If the request is for a class that implements System.Web.UI.Page and it is a rest method call, the WebServiceData class (that was explained in a previous post) is used to call the requested method from the Page. After the method has been called, the CompleteRequest method is called, bypassing all pipeline events and executing the EndRequest method. This allows MS AJAX to be able to call a method on a page instead of having to create a web service to call a method. (...)

Try to use the ASP.NET JavaScript Proxies, to check if you can capture the error using Microsoft Generated Code.

Chekhov answered 13/6, 2014 at 23:37 Comment(0)
C
1

This article suggests that there are two ways to extend WebMethods of which the SoapExtension is the easier. This other one shows an example how to write a SoapExtension. It looks like the place where you can do message validation.

Chur answered 14/6, 2014 at 14:26 Comment(0)
I
0

Here is a solution that replaces the internal RestHandler implementation with my own version. You can log the exception in the WriteExceptionJsonString method. This uses an answer provided on Dynamically replace the contents of a C# method? to swap out the method. I've confirmed it works for me if I add a call to ReplaceRestHandler in my Global.asax Application_Start method. Haven't run this very long or in production so use at your own risk.

using System;
using System.Collections.Specialized;
using System.IO;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using System.Web;
using Newtonsoft.Json;

namespace Royal.Common.WebStuff
{
    public static class RestHandlerUtils
    {
        internal static void WriteExceptionJsonString(HttpContext context, Exception ex, int statusCode)
        {
            string charset = context.Response.Charset;
            context.Response.ClearHeaders();
            context.Response.ClearContent();
            context.Response.Clear();
            context.Response.StatusCode = statusCode;
            context.Response.StatusDescription = HttpWorkerRequest.GetStatusDescription(statusCode);
            context.Response.ContentType = "application/json";
            context.Response.AddHeader("jsonerror", "true");
            context.Response.Charset = charset;
            context.Response.TrySkipIisCustomErrors = true;
            using (StreamWriter streamWriter = new StreamWriter(context.Response.OutputStream, new UTF8Encoding(false)))
            {
                if (ex is TargetInvocationException)
                    ex = ex.InnerException;
                var error = new OrderedDictionary();
                error["Message"] = ex.Message;
                error["StackTrace"] = ex.StackTrace;
                error["ExceptionType"] = ex.GetType().FullName;
                streamWriter.Write(JsonConvert.SerializeObject(error));
                streamWriter.Flush();
            }
        }

        public static void ReplaceRestHandler()
        {
            //https://mcmap.net/q/112642/-dynamically-replace-the-contents-of-a-c-method
            var methodToInject = typeof(RestHandlerUtils).GetMethod("WriteExceptionJsonString",
                BindingFlags.NonPublic | BindingFlags.Static);
            var asm = typeof(System.Web.Script.Services.ScriptMethodAttribute).Assembly;
            var rhtype = asm.GetType("System.Web.Script.Services.RestHandler");
            var methodToReplace = rhtype
                .GetMethod("WriteExceptionJsonString", BindingFlags.NonPublic | BindingFlags.Static, null,
                    new Type[] {typeof(HttpContext), typeof(Exception), typeof(int)}, null);

            RuntimeHelpers.PrepareMethod(methodToReplace.MethodHandle);
            RuntimeHelpers.PrepareMethod(methodToInject.MethodHandle);

            unsafe
            {
                if (IntPtr.Size == 4)
                {
                    int* inj = (int*) methodToInject.MethodHandle.Value.ToPointer() + 2;
                    int* tar = (int*) methodToReplace.MethodHandle.Value.ToPointer() + 2;
                    *tar = *inj;
                }
                else
                {
                    long* inj = (long*) methodToInject.MethodHandle.Value.ToPointer() + 1;
                    long* tar = (long*) methodToReplace.MethodHandle.Value.ToPointer() + 1;
                    *tar = *inj;
                }
            }
        }
    }
}
Idle answered 8/6, 2016 at 17:36 Comment(0)
P
0

@MichaelLiu's answer is great but breaks in classic mode (works in integrated mode). This is because of _response.Headers["jsonerror"] which is not supported in classic mode. I left that check off and still seems to work fine for me since all status 501 should be errors anyway. Can't think of a scenario where that extra check is needed.

Precisian answered 26/9, 2019 at 20:29 Comment(0)
A
-1

These links might help you to handle the error on the client side,

stackoverflow

unseenrevolution

asp.net

encosia

then you could trigger a control event from client side to pass the error through the server and do the logging.

Alsace answered 12/6, 2014 at 5:1 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.