From a721c60ad97e327d526918481e52d761cd231dae Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:52:23 -0500 Subject: [PATCH] feat(api,infra): implement GlobalExceptionHandler and enhance PlaidException for detailed error handling --- .../Common/GlobalExceptionHandler.cs | 30 ++++++++++++ src/FiscalOS.API/Program.cs | 1 + .../Accounts/Plaid/PlaidAccountService.cs | 49 +++++++++++++------ src/FiscalOS.Infra/Common/PlaidException.cs | 37 ++++++++++++++ 4 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 src/FiscalOS.API/Common/GlobalExceptionHandler.cs diff --git a/src/FiscalOS.API/Common/GlobalExceptionHandler.cs b/src/FiscalOS.API/Common/GlobalExceptionHandler.cs new file mode 100644 index 0000000..1df1fd4 --- /dev/null +++ b/src/FiscalOS.API/Common/GlobalExceptionHandler.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Diagnostics; + +internal sealed class GlobalExceptionHandler( + IProblemDetailsService problemDetailsService, + ILogger logger +) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken + ) + { + logger.LogError(exception, "An unhandled exception occurred"); + + httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + Exception = exception, + ProblemDetails = new ProblemDetails + { + Type = exception.GetType().Name, + Title = "Uh oh something has gone wrong.", + Detail = exception.Message + } + }); + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Program.cs b/src/FiscalOS.API/Program.cs index 2154197..4a1118b 100644 --- a/src/FiscalOS.API/Program.cs +++ b/src/FiscalOS.API/Program.cs @@ -3,6 +3,7 @@ var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.Services.AddValidation(); +builder.Services.AddExceptionHandler(); builder.Services.AddProblemDetails(); builder.Services.ConfigureHttpJsonOptions(static options => diff --git a/src/FiscalOS.Infra/Accounts/Plaid/PlaidAccountService.cs b/src/FiscalOS.Infra/Accounts/Plaid/PlaidAccountService.cs index 3c8bf8c..69cd4e6 100644 --- a/src/FiscalOS.Infra/Accounts/Plaid/PlaidAccountService.cs +++ b/src/FiscalOS.Infra/Accounts/Plaid/PlaidAccountService.cs @@ -31,7 +31,7 @@ internal sealed class PlaidAccountService : IPlaidAccountService var assemblyName = Assembly.GetExecutingAssembly().GetName().FullName; var environmentName = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - var ltr = await _client.LinkTokenCreateAsync(new() + var linkTokenResponse = await _client.LinkTokenCreateAsync(new() { ClientName = $"{assemblyName}_{environmentName}", Products = [Products.Transactions], @@ -44,56 +44,73 @@ internal sealed class PlaidAccountService : IPlaidAccountService Webhook = _options.Value.Webhook, }).ConfigureAwait(false); - if (ltr.IsSuccessStatusCode is false) + if (linkTokenResponse.IsSuccessStatusCode is false) { - throw new PlaidException("Unable to create link token"); + throw new PlaidException( + "Unable to create link token", + linkTokenResponse.Error, + linkTokenResponse.RequestId, + (int?)linkTokenResponse.StatusCode + ); } - return ltr.LinkToken; + return linkTokenResponse.LinkToken; } public async Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken) { - var ptr = await _client.ItemPublicTokenExchangeAsync(new() + var publicTokenResponse = await _client.ItemPublicTokenExchangeAsync(new() { PublicToken = publicToken, }).ConfigureAwait(false); - if (ptr.IsSuccessStatusCode is false) + if (publicTokenResponse.IsSuccessStatusCode is false) { - throw new PlaidException("Unable to exchange public token for access token"); + throw new PlaidException( + "Unable to exchange public token for access token", + publicTokenResponse.Error, + publicTokenResponse.RequestId, + (int?)publicTokenResponse.StatusCode + ); } - return (ptr.ItemId, ptr.AccessToken); + return (publicTokenResponse.ItemId, publicTokenResponse.AccessToken); } public async Task> GetAccountsAsync(string accessToken) { - var ar = await _client.AccountsGetAsync(new() + var accountResponse = await _client.AccountsGetAsync(new() { AccessToken = accessToken, }).ConfigureAwait(false); - if (ar.IsSuccessStatusCode is false) + if (accountResponse.IsSuccessStatusCode is false) { - throw new PlaidException("Unable to retrieve accounts"); + throw new PlaidException( + "Unable to retrieve accounts", + accountResponse.Error, + accountResponse.RequestId, + (int?)accountResponse.StatusCode + ); } - return [.. ar.Accounts]; + return [.. accountResponse.Accounts]; } public async Task GetItemAsync(string accessToken) { - var ar = await _client.ItemGetAsync(new() + var itemResponse = await _client.ItemGetAsync(new() { AccessToken = accessToken, }).ConfigureAwait(false); - if (ar.IsSuccessStatusCode is false) + if (itemResponse.IsSuccessStatusCode is false) { - throw new PlaidException("Unable to retrieve item"); + throw new PlaidException( + "Unable to retrieve item" + ); } - return ar.Item; + return itemResponse.Item; } } diff --git a/src/FiscalOS.Infra/Common/PlaidException.cs b/src/FiscalOS.Infra/Common/PlaidException.cs index 7ee6971..378e64b 100644 --- a/src/FiscalOS.Infra/Common/PlaidException.cs +++ b/src/FiscalOS.Infra/Common/PlaidException.cs @@ -2,6 +2,13 @@ namespace FiscalOS.Infra.Common; public class PlaidException : Exception { + public PlaidError? Error { get; } + public string? RequestId { get; } + public int? StatusCode { get; } + public string? ErrorCode => Error?.ErrorCode; + public string? ErrorType => Error?.ErrorType; + public string? ErrorMessage => Error?.ErrorMessage; + public PlaidException() { } @@ -13,4 +20,34 @@ public class PlaidException : Exception public PlaidException(string message, Exception innerException) : base(message, innerException) { } + + public PlaidException( + string message, + PlaidError? error = null, + string? plaidRequestId = null, + int? statusCode = null, + Exception? innerException = null + ) : base(message, innerException) + { + Error = error; + RequestId = plaidRequestId; + StatusCode = statusCode; + + if (error is not null) + { + Data[nameof(ErrorCode)] = error.ErrorCode; + Data[nameof(ErrorType)] = error.ErrorType; + Data[nameof(ErrorMessage)] = error.ErrorMessage; + } + + if (plaidRequestId is not null) + { + Data[nameof(RequestId)] = plaidRequestId; + } + + if (statusCode is not null) + { + Data[nameof(StatusCode)] = statusCode; + } + } } \ No newline at end of file