diff --git a/Frends.HTTP.Request/CHANGELOG.md b/Frends.HTTP.Request/CHANGELOG.md index d744f1d..38aa80a 100644 --- a/Frends.HTTP.Request/CHANGELOG.md +++ b/Frends.HTTP.Request/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.13.0] - 2026-08-17 + +### Added + +- Added `ThrowErrorOnFailure` and `ErrorMessageOnFailure` options to control error handling: when `ThrowErrorOnFailure` is set to false, the task returns a `Result` with `Success = false` and an `Error` object instead of throwing an exception. +- The `Result` type now includes `Success` and `Error` properties to indicate task outcome. + ## [1.12.0] - 2026-06-12 ### Fixed diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/ErrorHandlerTest.cs b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/ErrorHandlerTest.cs new file mode 100644 index 0000000..ff1b1f7 --- /dev/null +++ b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/ErrorHandlerTest.cs @@ -0,0 +1,59 @@ +using System; +using System.Threading; +using Frends.HTTP.Request.Definitions; +using NUnit.Framework; + +namespace Frends.HTTP.Request.Tests; + +[TestFixture] +internal class ErrorHandlerTest +{ + private const string InvalidUrl = "http://thisdomaindoesnotexist.invalid/"; + private const string CustomErrorMessage = "CustomErrorMessage"; + + private static Input InvalidInput() => new Input + { + Method = Method.GET, + Url = InvalidUrl, + Headers = Array.Empty
(), + Message = string.Empty, + }; + + private static Options DefaultOptions() => new Options + { + ConnectionTimeoutSeconds = 5, + ThrowErrorOnFailure = true, + ErrorMessageOnFailure = string.Empty, + }; + + [Test] + public void Should_Throw_Error_When_ThrowErrorOnFailure_Is_True() + { + var ex = Assert.ThrowsAsync(async () => + await HTTP.Request(InvalidInput(), DefaultOptions(), CancellationToken.None)); + Assert.That(ex, Is.Not.Null); + } + + [Test] + public async System.Threading.Tasks.Task Should_Return_Failed_Result_When_ThrowErrorOnFailure_Is_False() + { + var options = DefaultOptions(); + options.ThrowErrorOnFailure = false; + var result = await HTTP.Request(InvalidInput(), options, CancellationToken.None); + Assert.That(result.Success, Is.False); + Assert.That(result.Error, Is.Not.Null); + Assert.That(result.Error.Message, Is.Not.Null.And.Not.Empty); + Assert.That(result.Error.AdditionalInfo, Is.Not.Null); + } + + [Test] + public void Should_Use_Custom_ErrorMessageOnFailure() + { + var options = DefaultOptions(); + options.ErrorMessageOnFailure = CustomErrorMessage; + var ex = Assert.ThrowsAsync(async () => + await HTTP.Request(InvalidInput(), options, CancellationToken.None)); + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message, Does.Contain(CustomErrorMessage)); + } +} diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs index 5863469..b2f0204 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs @@ -86,10 +86,11 @@ public void RequestShouldThrowExceptionIfUrlEmpty() ThrowExceptionOnErrorResponse = true }; - var ex = Assert.ThrowsAsync(async () => + var ex = Assert.ThrowsAsync(async () => await HTTP.Request(input, options, CancellationToken.None)); ClassicAssert.IsTrue(ex.Message.Contains("Url can not be empty.")); + Assert.That(ex.InnerException, Is.TypeOf()); } [TestMethod] @@ -108,11 +109,12 @@ public void RequestShouldThrowExceptionIfOptionIsSet() ThrowExceptionOnErrorResponse = true }; - var ex = Assert.ThrowsAsync(async () => + var ex = Assert.ThrowsAsync(async () => await HTTP.Request(input, options, CancellationToken.None)); ClassicAssert.IsTrue( ex.Message.Contains($"Request to '{BasePath}/invalid' failed with status code 404")); + Assert.That(ex.InnerException, Is.TypeOf()); } [TestMethod] @@ -230,10 +232,11 @@ public void RequestShouldAddClientCertificate() CertificateThumbprint = thumbprint }; - var ex = Assert.ThrowsAsync(async () => + var ex = Assert.ThrowsAsync(async () => await HTTP.Request(input, options, CancellationToken.None)); ClassicAssert.IsTrue(ex.Message.Contains($"Certificate with thumbprint: '{thumbprint}' not")); + Assert.That(ex.InnerException, Is.TypeOf()); } [TestMethod] diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Error.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Error.cs new file mode 100644 index 0000000..c348cf5 --- /dev/null +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Error.cs @@ -0,0 +1,21 @@ +using System; + +namespace Frends.HTTP.Request.Definitions; + +/// +/// Error details returned when the task fails and ThrowErrorOnFailure is false. +/// +public class Error +{ + /// + /// Error message. + /// + /// An error occurred while processing the request. + public string Message { get; internal set; } + + /// + /// Additional error information, such as the original exception. + /// + /// null + public Exception AdditionalInfo { get; internal set; } +} diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Header.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Header.cs index fac879e..ee626ef 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Header.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Header.cs @@ -8,10 +8,12 @@ public class Header /// /// Name of header. /// + /// Content-Type public string Name { get; set; } /// /// Value of header. /// + /// application/json public string Value { get; set; } } diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs index 96f564b..476c390 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs @@ -188,4 +188,19 @@ public class Options /// Default [DefaultValue(SslVersion.Default)] public SslVersion SslProtocolVersion { get; set; } = SslVersion.Default; + + /// + /// Whether to throw an error on failure. + /// + /// true + [DefaultValue(true)] + public bool ThrowErrorOnFailure { get; set; } = true; + + /// + /// Overrides the error message on failure. + /// + /// HTTP request failed: connection refused + [DisplayFormat(DataFormatString = "Text")] + [DefaultValue("")] + public string ErrorMessageOnFailure { get; set; } = string.Empty; } diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Result.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Result.cs index 6d7814c..ce7d851 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Result.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Result.cs @@ -7,6 +7,18 @@ namespace Frends.HTTP.Request.Definitions; /// public class Result { + /// + /// Indicates whether the task completed successfully. + /// + /// true + public bool Success { get; private set; } + + /// + /// Error details. Null when Success is true. + /// + /// null + public Error Error { get; private set; } + /// /// Body of response /// @@ -25,17 +37,19 @@ public class Result /// 200 public int StatusCode { get; private set; } - internal Result(string body, Dictionary headers, int statusCode) + internal Result(object body, Dictionary headers, int statusCode) { + Success = true; + Error = null; Body = body; Headers = headers; StatusCode = statusCode; } - internal Result(object body, Dictionary headers, int statusCode) + internal Result(bool success, Error error) { - Body = body; - Headers = headers; - StatusCode = statusCode; + Success = success; + Error = error; + StatusCode = -1; } } diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj index 99453c3..f19245c 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj @@ -2,7 +2,7 @@ net8.0 - 1.12.0 + 1.13.0 Frends Frends Frends @@ -16,9 +16,9 @@ - + PreserveNewest - + @@ -26,6 +26,14 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Helpers/ErrorHandler.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Helpers/ErrorHandler.cs new file mode 100644 index 0000000..35c2c2c --- /dev/null +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Helpers/ErrorHandler.cs @@ -0,0 +1,53 @@ +using System; +using Frends.HTTP.Request.Definitions; + +namespace Frends.HTTP.Request.Helpers; + +/// +/// Converts an exception into a failed Result object or rethrows based on task options. +/// +internal static class ErrorHandler +{ + /// The exception to handle. + /// Task options that control whether failures are returned as a Result object or thrown. + /// + /// When true, an OperationCanceledException is rethrown immediately. + /// When false, cancellation is handled like any other failure. + /// + /// A failed Result object when the exception is handled instead of rethrown. + internal static Result Handle(this Exception exception, Options options, bool throwCanceled = true) + { + ThrowIfCanceled(exception, throwCanceled); + if (options.ThrowErrorOnFailure) ThrowBaseException(exception, options.ErrorMessageOnFailure); + + return ReturnResult(exception, options.ErrorMessageOnFailure); + } + + private static void ThrowIfCanceled(Exception exception, bool throwCanceled = true) + { + if (throwCanceled && exception is OperationCanceledException) throw exception; + } + + private static void ThrowBaseException(Exception exception, string customMessage = null) + { + if (string.IsNullOrEmpty(customMessage)) + throw new Exception(exception.Message, exception); + + throw new Exception(customMessage, exception); + } + + private static Result ReturnResult(Exception exception, string customMessage = null) + { + var errorMessage = string.IsNullOrEmpty(customMessage) + ? exception.Message + : $"{customMessage}: {exception.Message}"; + + return new Result( + false, + new Error + { + Message = errorMessage, + AdditionalInfo = exception, + }); + } +} diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs index 9719dba..5606f36 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Request.cs @@ -16,6 +16,7 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography.X509Certificates; using Frends.HTTP.Request.Definitions; +using Frends.HTTP.Request.Helpers; [assembly: InternalsVisibleTo("Frends.HTTP.Request.Tests")] @@ -115,6 +116,10 @@ CancellationToken cancellationToken return response; } + catch (Exception ex) + { + return ex.Handle(options); + } finally { httpContent?.Dispose();