Handle ASP.NET Core exceptions gracefully in TwiML webhooks

January 09, 2023
Written by
Reviewed by

Handle ASP.NET Core exceptions gracefully in TwiML webhooks

A lot of things can go wrong in your application leading to exceptions, and when these exceptions are not caught, ASP.NET Core will respond with an 500 Internal Server Error and, depending on your project and environment, render an HTML error page for you. However, when you're implementing an incoming message or voice call webhook, Twilio expects you to respond with HTTP status 200 and TwiML instructions.

In this tutorial, you'll learn how to extend ASP.NET Core to respond with TwiML instructions to respond that an error occurred.

Prerequisites

Here’s what you will need to follow along:

You can find the source code for this tutorial on GitHub. Use it if you run into any issues, or submit an issue, if you run into problems.

Clone the sample project

To illustrate the problem, I have provided you with a sample ASP.NET Core project that handles the incoming message and voice call webhooks using controllers and minimal API endpoints, but they have errors in them.

Open a shell and clone the sample project using the following command:

git clone -b start https://github.com/Swimburger/TwimlErrorHandling.git
cd TwimlErrorHandling

Next, open the project using your preferred IDE and inspect the following files:

Controllers/TwilioController.cs:

using Microsoft.AspNetCore.Mvc;
using Twilio.AspNet.Core;
using Twilio.TwiML;

namespace TwimlErrorHandling.Controllers;

[Route("{controller}/{action}")]
public class TwilioController : Controller
{
    public IActionResult Message()
    {
        var zero = 0;
        var result = 1 / zero;
        return new MessagingResponse()
            .Message($"1/0 is {result}!")
            .ToTwiMLResult();
    }
    
    public IActionResult Voice()
    {
        var zero = 0;
        var result = 1 / zero;
        return new VoiceResponse()
            .Say($"1/0 is {result}!")
            .ToTwiMLResult();
    }
}

TwilioEndpoints.cs:

using Twilio.AspNet.Core;
using Twilio.TwiML;

namespace TwimlErrorHandling;

public static class TwilioEndpoints
{
    public static IEndpointRouteBuilder MapTwilioEndpoints(this IEndpointRouteBuilder builder)
    {
        builder.Map("/minimal-message", OnMessage);
        builder.Map("/minimal-voice", OnVoice);
        return builder;
    }
    
    private static IResult OnMessage()
    {
        var zero = 0;
        var result = 1 / zero;
        return new MessagingResponse()
            .Message($"1/0 is {result}!")
            .ToTwiMLResult();
    }
    
    private static IResult OnVoice()
    {
        var zero = 0;
        var result = 1 / zero;
        return new VoiceResponse()
            .Say($"1/0 is {result}!")
            .ToTwiMLResult();
    }
}

All the controller actions and minimal API endpoints are supposed to respond with the result of dividing 1 by 0. However, as my father used to teach in his math classes, "Delen door null is flauwekul", which loosely translates to "Dividing by zero is bulls**t". So none of these endpoints will succeed as all of them will throw a DivideByZeroException.

This application is using the Twilio helper library for .NET to generate the TwiML and the Twilio helper library for ASP.NET Core to write the TwiML to the HTTP response.

Test the project before handling exceptions gracefully

To test the project, run the application using the following command:

dotnet run

Then, open the browser and browse to the application URL with the /twilio/message path appended. During development, you will see a nice error page with exception details to help you debug the problem. In production, no page is returned, only a "HTTP ERROR 500". You will see the same result for paths /twilio/voice, /minimal-message, and /minimal-voice.

If you're using the MVC project template, you'll also get a nice error page in production which doesn't show the exception details to your users.

To see how this would behave in your Twilio application, you'll need to configure the message and voice webhook on your Twilio Phone Number. But before you can do that, you'll need to make your locally running project accessible to the internet. You can quickly do this using ngrok which creates a secure tunnel to the internet for you.

In the following command, replace [YOUR_LOCALHOST_URL] with the URL that is printed to the console when executing dotnet run. Then, leave your .NET application running, and run the following command in a separate shell:

ngrok http [YOUR_LOCALHOST_URL]

ngrok will print the Forwarding URL you'll need to publicly access your local application.

Now, go to the Twilio Console in your browser, use the left navigation to navigate to Phone Numbers > Manage > Active Numbers, and then select the Twilio Phone Number you want to test with.

On the phone number configuration page, locate the "A CALL COMES IN" section. Underneath that, set the first dropdown to Webhook, the text box to the ngrok Forwarding URL, adding on the /twilio/voice path. Then, set the second dropdown to "HTTP POST". Follow the same steps at the "A MESSAGE COMES IN" section, but use the /twilio/message path instead, and then click Save.

Now call and text the Twilio Phone Number. When calling, you should hear "We're sorry, an application error has occurred. Goodbye.". When texting, you should not get any response at all.

Now let's see how you can handle these errors gracefully by responding with HTTP status 200 and TwiML instructions.

If you wish to test the /minimal-message and /minimal-voice endpoints, change the path for the webhooks in the phone number configuration page.

Handling TwiML webhooks gracefully

The easiest way to solve this problem, would be to wrap all your code in a try/catch block and, inside the catch block, return a user-friendly message, like this:

try
{
    // all your code
}
catch (Exception e)
{
    // TODO: log the error
    return new MessagingResponse()
        .Message("An unexpected error occurred. Please try again.")
        .ToTwiMLResult();
}

While this works, it's not a good solution and considered a code smell. It indents all your code, making the code less readable, you have to repeat the same logic in every endpoint.

This is why ASP.NET Core has some built-in APIs to handle exceptions for your entire application. While there are many ways to do this, I'll share two solutions with you, one that applies specifically to MVC, and one that applies to all endpoints including MVC. Let's start with the one that applies to all endpoints, as that's the one I recommend the most.

Use the Exception Handler

Open the Program.cs file and add the following lines of code:


using TwimlErrorHandling;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllers();

var app = builder.Build();

app.UseExceptionHandler("/error");

app.UseHttpsRedirection();

app.UseRouting();

app.MapErrorEndpoint();

app.MapControllers();

app.MapTwilioEndpoints();

app.Run();

The UseExceptionHandler("/error") method will catch any uncaught exceptions in your application, log it as an error, and then reroute the request to the given path of /error. Currently, nothing handles requests going to /error, which is why you'll add an endpoint for /error next, that will be mapped inside MapErrorEndpoint().

Create a new file called ErrorEndpoint.cs and add the following code:

using Microsoft.AspNetCore.Diagnostics;
using Twilio.AspNet.Core;
using Twilio.TwiML;

namespace TwimlErrorHandling;

public static class ErrorEndpoint
{
    private const string GenericErrorMessage = "An unexpected error occurred. Please try again.";

    public static IEndpointRouteBuilder MapErrorEndpoint(this IEndpointRouteBuilder builder)
    {
        builder.Map("/error", OnError);
        return builder;
    }

    private static IResult OnError(HttpContext context)
    {
        var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
        if (exceptionFeature?.Endpoint is not null)
        {
            if (exceptionFeature.Endpoint.Metadata.GetMetadata<CatchWithMessageTwimlAttribute>() is not null)
                return TwimlMessageError(context.Response);
            if (exceptionFeature.Endpoint.Metadata.GetMetadata<CatchWithVoiceTwimlAttribute>() is not null)
                return TwimlVoiceError(context.Response);
        }

        return StatusCodeError();
    }

    private static IResult StatusCodeError()
        => Results.StatusCode(StatusCodes.Status500InternalServerError);

    private static IResult TwimlMessageError(HttpResponse response)
    {
        response.StatusCode = StatusCodes.Status200OK;
        return new MessagingResponse()
            .Message(GenericErrorMessage)
            .ToTwiMLResult();
    }

    private static IResult TwimlVoiceError(HttpResponse response)
    {
        response.StatusCode = StatusCodes.Status200OK;
        return new VoiceResponse()
            .Say(GenericErrorMessage)
            .ToTwiMLResult();
    }
}

public class CatchWithMessageTwimlAttribute : Attribute
{
}

public class CatchWithVoiceTwimlAttribute : Attribute
{
}

First, take note of the MapErrorEndpoint method which is an extension method, extending IEndpointRouteBuilder. This is the method that you're calling inside of Program.cs. MapErrorEndpoint maps the /error path to the OnError method.

OnError retrieves the details of the exception that occurred and checks if the endpoint metadata contains the CatchWithMessageTwimlAttribute or the CatchWithVoiceTwimlAttribute.
If the metadata contains the CatchWithMessageTwimlAttribute, the TwimlMessageError method will respond with the following TwiML:

<?xml version="1.0" encoding="utf-8"?>
<Response>
    <Message>An unexpected error occurred. Please try again.</Message>
</Response>

If the metadata contains the CatchWithVoiceTwimlAttribute, the TwimlVoiceError method will respond with the following TwiML:

<?xml version="1.0" encoding="utf-8"?>
<Response>
    <Say>An unexpected error occurred. Please try again.</Say>
</Response>

For any other endpoint, the StatusCodeError method will respond with HTTP status 500, just as before.

The CatchWithMessageTwimlAttribute and CatchWithVoiceTwimlAttribute are defined at the bottom of the file and are empty attributes without any logic. You need to use these attributes to mark your endpoints, such as areas, controllers, actions, and Minimal APIs. So let's do that next.

Update the Controllers/TwilioController.cs file as follows:


...

[Route("{controller}/{action}")]
public class TwilioController : Controller
{
    [CatchWithMessageTwiml]
    public IActionResult Message()
    {
        ...
    }
    
    [CatchWithVoiceTwiml]
    public IActionResult Voice()
    {
        ...
    }
}

If all actions in your controller are for messaging or voice, you can apply the CatchWithMessageTwiml or CatchWithVoiceTwiml attribute on the controller instead of the actions, and that will apply them to all the actions within the controller. The same applies when configuring the attribute globally or for an area, which applies them to all controllers and their actions inside.

Next, update the TwilioEndpoints.cs file as follows:


...

public static class TwilioEndpoints
{
    public static IEndpointRouteBuilder MapTwilioEndpoints(this IEndpointRouteBuilder builder)
    {
        ...
    }
    
    [CatchWithMessageTwiml]
    private static IResult OnMessage()
    {
        ...
    }
    
    [CatchWithVoiceTwiml]
    private static IResult OnVoice()
    {
        ...
    }
}

I wrote these two endpoints inside the TwilioEndpoints class with dedicated methods to organize my code the way I prefer. However, you can also map these endpoints using lambdas directly in the Program.cs file. If you prefer that, you can apply these attributes using two different options.

The first option is to add the attribute in front of the lambda:

app.Map("/minimal-message", [CatchWithMessageTwiml]() =>
{
    // your code;
});

The second option is to use the .WithMetadata method:

app.Map("/minimal-message", () =>
{
    // your code;
}).WithMetadata(new CatchWithMessageTwimlAttribute());

You can also apply metadata to endpoint groups.

To test this out, leave the ngrok tunnel running, switch back to the shell where your app is running, and stop your application by pressing ctrl + c. Then start it again using the dotnet run command. Now, give your Twilio Phone Number another call, and send it another message.

Now you'll be responded with, "An unexpected error occurred. Please try again.".

Use exception filters in MVC

MVC uses the concept of filters, which lets you run code at different stages of the MVC pipeline. One of the types of filters is the exception filter, which is called when an exception is not caught within your controller. Let's create an exception filter to catch the exception and respond with the appropriate TwiML.

First, create a new file TwilioExceptionFilters.cs and add the following code:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Twilio.AspNet.Core;
using Twilio.TwiML;

namespace TwimlErrorHandling;

internal enum ErrorTwimlType
{
    Message,
    Voice
}

internal class GenericErrorTwimlExceptionFilter : IExceptionFilter
{
    private const string GenericErrorMessage = "An unexpected error occurred. Please try again.";
    private readonly ILogger<GenericErrorTwimlExceptionFilter> logger;
    private readonly ErrorTwimlType twimlType;

    public GenericErrorTwimlExceptionFilter(
        ILogger<GenericErrorTwimlExceptionFilter> logger,
        ErrorTwimlType twimlType
    )
    {
        this.logger = logger;
        this.twimlType = twimlType;
    }

    public void OnException(ExceptionContext context)
    {
        logger.LogError(context.Exception, "An unhandled exception has occurred while executing the request.");
        switch (twimlType)
        {
            case ErrorTwimlType.Message:
                context.Result = new MessagingResponse()
                    .Message(GenericErrorMessage)
                    .ToTwiMLResult();
                break;
            case ErrorTwimlType.Voice:
                context.Result = new VoiceResponse()
                    .Say(GenericErrorMessage)
                    .ToTwiMLResult();
                break;
        }
    }
}

The GenericErrorTwimlExceptionFilter will need to know whether it should respond with messaging TwiML or voice TwiML, hence the need for the ErrorTwimlType enum. The GenericErrorTwimlExceptionFilter receives the ErrorTwimlType via its constructor, and also receives a logger that is injected by ASP.NET Core's built-in dependency injection container.

To develop an exception filter, you need to implement the IExceptionFilter interface, (or the IAsyncExceptionFilter interface if you need to do async work). To implement IExceptionFilter, you need to add the OnException method where you can add your error handling logic.

OnException will log the exception as an error, then generate either messaging or voice TwiML which is set as the result using context.Result. Thus, ASP.NET Core will respond with HTTP status 200 and the generic error TwiML.

To use this exception filter, you need to apply it to your areas, controllers, or actions like this:


...

[Route("{controller}/{action}")]
public class TwilioController : Controller
{
    [TypeFilter(typeof(GenericErrorTwimlExceptionFilter), Arguments = new object[] {ErrorTwimlType.Message})]
    public IActionResult Message()
    {
        ...
    }

    [TypeFilter(typeof(GenericErrorTwimlExceptionFilter), Arguments = new object[] {ErrorTwimlType.Voice})]
    public IActionResult Voice()
    {
        ...
    }
}

This works, but having to specify the type and passing in a new array with an enum isn't very elegant, so let's make it cleaner.

Go back to TwilioExceptionFilters.cs and add the following code at the end of the file:

public class GenericErrorTwimlMessageAttribute : TypeFilterAttribute
{
    public GenericErrorTwimlMessageAttribute() : base(typeof(GenericErrorTwimlExceptionFilter))
    {
        Arguments = new[] {(object) ErrorTwimlType.Message};
    }
}

public class GenericErrorTwimlVoiceAttribute : TypeFilterAttribute
{
    public GenericErrorTwimlVoiceAttribute() : base(typeof(GenericErrorTwimlExceptionFilter))
    {
        Arguments = new[] {(object) ErrorTwimlType.Voice};
    }
}

These two attributes essentially alias what you were doing directly inside your controller. So now you can use [GenericErrorTwimlMessage] and [GenericErrorTwimlVoice] as attributes, like this:


[GenericErrorTwimlMessage]
public IActionResult Message()
{
    ...
}

[GenericErrorTwimlVoice]
public IActionResult Voice()
{
    ...
}

Much more elegant!

Which is better?

The exception handler solution works for all of ASP.NET Core, not just MVC, and the logging is handled by ASP.NET Core. You also have to handle the case where the endpoint is not supposed to respond with TwiML, such as responding with an empty error 500, or a nice HTML error 500 page.

The exception filter solution only works for MVC, but it fits in and integrates well within MVC, which may be your preferred framework. You don't have to handle cases where you shouldn't respond with TwiML, because it'll be handled by another exception filter or the exception handler.

Technically, you don't have to chose, as you can use both in the same project. They both get the job done in a similar way, so it's up to your and your teams' preference.

Fallback webhook

Both the messaging and the voice webhook have a fallback webhook that is called in case the primary webhook returns an error. You can configure this in the phone number configuration page under the "PRIMARY HANDLER FAILS" sections. How you handle the fallback webhook is not prescribed by Twilio, so you could use it as you see fit.

You could configure the fallback webhook to send the HTTP request back to your application, and your application could respond with the same generic error message TwiML. However, if your application is completely down, the fallback webhook would also fail. I recommend pointing the fallback webhook to a different host, maybe running the same application, maybe running a different application, or even better, you could use a TwiML bin, a Twilio Function, or a Studio Flow.

Next steps

You learned how to extend ASP.NET Core to handle uncaught exceptions and respond with the appropriate TwiML. Inside the exception handler and the exception filter, you have access to the original HTTP request, which you could use to generate a different response. So, if the preferred language is stored in the request URL or in a cookie, you could retrieve the language and respond using that language instead of hard coding it.

Here are a couple more resources to further your learning on ASP.NET Core and Twilio:

We can't wait to see what you build. Let us know!

Niels Swimberghe is a Belgian American software engineer and technical content creator at Twilio. Get in touch with Niels on Twitter @RealSwimburger and follow Niels’ personal blog on .NET, Azure, and web development at swimburger.net.