Search code examples
c#asp.net-coreexceptionasp.net-core-3.1ambiguous

.NET Core 3 ExceptionHandler with Generic Logger parameter - AmbiguousMatchException


I am trying to implement .NET Core 3 app.UseExceptionHandler for an API project, but I have a generic logger per API controller that I would like to pass into the error method. If, for instance, the error happens in my WorkingController, I would like to have ILogger<WorkingController> be the logging entity. I have found that using the built-in ExceptionHandler, I lose some of the context of the request, and I would like to capture this context if possible.

Here's what all of my API methods used to look like before:

[Route("api/working")]
[ApiController]
public class WorkingController 
{
    private readonly ILogger<WorkingController> _logger;
    
    public WorkingController(ILogger<WorkingController> logger)
    {
        _logger = logger;
    }
    
    [Route("workingRoute")]
    public IActionResult SomeMethod()
    {
        _logger.LogInformation("Starting SomeMethod");
        try
        {
            // doing some stuff here
        }
        catch(Exception ex)
        {
            _logger.LogError(ex, "Something happened");
            return Problem();
        }
        return Ok();
    }
}

I tried setting up a BaseErrorController from which other Controllers could inherit:

[ApiController]
public abstract class BaseErrorController<T> : ControllerBase
{
    protected readonly ILogger<T> Logger;

    public BaseErrorController(ILogger<T> logger)
    {
        Logger = logger;
    }

    [AllowAnonymous]
    [Route("/error")]
    public IActionResult Error()
    {
        var context = HttpContext.Features.Get<IExceptionHandlerPathFeature>();

        if (context != null)
        {
            var ex = context.Error;
            Logger.LogError(ex, $"{context.Path} failed - {ex.Message}");
            return Problem(
                    detail: context.Error.StackTrace,
                    title: context.Error.Message);
        }

        return Problem();
    }

}

And now my former WorkingController looks like this, which is arguably a lot cleaner (and less code):

[Route("api/working")]
[ApiController]
public class WorkingController : BaseErrorController<WorkingController>
{
    public WorkingController(ILogger<WorkingController> logger) : base(logger) { }
    
    [Route("workingRoute")]
    public IActionResult SomeMethod()
    {
        Logger.LogInformation("Starting SomeMethod");
        // doing some stuff here
        return Ok();
    }
}

In Startup.cs, I'm registering this all with app.UseExceptionHandler("/error"), and it seems to work OK . . . except now in my logs, I see the following error (because I have more than one controller implementing this base controller):

An exception was thrown attempting to execute the error handler. 
Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware
Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException: The request matched multiple endpoints. 
Matches:     Namespace.Controllers.WorkingController.Error (Namespace)  
             Namespace.Controllers.AnotherController.Error (Namespace)     
at Microsoft.AspNetCore.Routing.Matching.DefaultEndpointSelector.ReportAmbiguity(CandidateState[] candidateState)          
at Microsoft.AspNetCore.Routing.Matching.DefaultEndpointSelector.ProcessFinalCandidates(HttpContext httpContext, CandidateState[] candidateState)          
at Microsoft.AspNetCore.Routing.Matching.DefaultEndpointSelector.Select(HttpContext httpContext, CandidateState[] candidateState)          
at Microsoft.AspNetCore.Routing.Matching.DfaMatcher.MatchAsync(HttpContext httpContext)          
at Microsoft.AspNetCore.Routing.Matching.DataSourceDependentMatcher.MatchAsync(HttpContext httpContext)          
at Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware.Invoke(HttpContext httpContext)          
at Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware.HandleException(HttpContext context, ExceptionDispatchInfo edi)

Does anybody have an idea what might be the solution here? Is there an overload of the ExceptionHandler that might be what I'm looking for? Is this solution too boutique, and I should go back to what I was doing before? Help me, Stack Overflow. Thank you!


Solution

  • Unfortunately, there is simply no built-in way to do this cleanly. You could find a package to help here (I haven't looked), or you could want to re-write some parts of ASP.NET Core, but I wouldn't really want to do that.

    There is another way of doing this, which depending on which version you like more, is recommended/recommended against, but I'm pro of the former. Instead of throwing/catching exceptions at the Controller level, I treat Controllers as the dumbest thing possible, so they just call some service and that's it.

    If you want to know where an exception was thrown, or you specifically want the exception to go uncaught, a strategy my team follows is to create custom exceptions. You could then leave these uncaught (and the HTTP500 will be returned to the caller) or you could have a custom Middleware and define there what should happen.

    The following is an example, written entirely here so there may be some changes needed, and it's solely to demonstrate a possible approach, not a working demo.

    Given some exceptions valid to your domain:

    public class UserNotFoundException : Exception { public Guid UserId { get; set; } }
    public class InvalidModelException : Exception { }
    

    And an exception handler:

    public class MyCustomExceptionHandlerMiddleware
    {
        private readonly ILogger<MyCustomExceptionHandlerMiddleware> _logger;
    
        public MyCustomExceptionHandlerMiddleware(ILogger<MyCustomExceptionHandlerMiddleware> logger)
        {
            _logger = logger;
        }
    
        public async Task Invoke(RequestDelegate next)
        {
            try
            {
                await next(); // without this, the request doesn't execute any further
            }
            catch (UserNotFoundException userNotFound)
            {
                _logger.LogError(userNotFound, "The user was not found");
                // manipulate the response here, possibly return HTTP404
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Something really bad happened");
               //  manipulate the response here
            }
        }
    }
    

    You could have something like this:

    public class UsersService : IUsersService
    {
        private readonly ILogger<UsersService> _logger;
        private readonly UsersContext _context;
    
        // assume UsersContext is an EntityFramework context or some database service
        public UsersService(ILogger<UsersService> logger, UsersContext context)
        {
            _logger = logger;
            _context = context;
        }
    
        public async Task<User> GetUserAsync(Guid userId)
        {
            try 
            {
                if (userId == Guid.Empty)
                {
                    throw new InvalidModelException();
                }
    
                var user = await _context.FindUserAsync(userId);
    
                if (user == null)
                {
                    throw new UserNotFoundException(userId);
                }
    
                return user;
            }
            catch (InvalidModelException invalidModel)
            {
                _logger.LogWarning("The received user id is empty");
                return null;
            }
        }
    }
    

    And its corresponding controller:

    public class UsersController : ControllerBase
    {
        private readonly IUsersService _usersService;
    
        public UsersController(IUsersService usersService)
        {
            _usersService = usersService;
        }
    
        [HttpGet("userId:guid")]
        public async Task<IActionResult> GetUser(Guid userId)
        {
            var user = await _usersService.GetUserAsync(userId);
    
            if (user == null)
            {
                return BadRequest();
            }
    
            return Ok(user);
        }
    }
    

    Again, this is just an example to demonstrate how you could approach this, normally you'd do input validation in a more consistent way.