From 361d233a9d75fe53daf14cc2b31b2543c571cdcb Mon Sep 17 00:00:00 2001 From: Mihai Radu Date: Wed, 6 Aug 2025 19:07:26 +0300 Subject: [PATCH] add support for Exception.Data propagation --- .../js/src/std/bcl/errors/CoreIpcError.ts | 2 + .../Services/ISystemService.cs | 1 + .../Services/SystemService.cs | 9 ++++ src/UiPath.CoreIpc.Tests/SystemTests.cs | 45 ++++++++++++++++++ src/UiPath.CoreIpc.Tests/TestBase.cs | 4 +- src/UiPath.CoreIpc/Helpers/Helpers.cs | 3 +- src/UiPath.CoreIpc/Helpers/Router.cs | 2 + src/UiPath.CoreIpc/Server/ContractSettings.cs | 3 ++ src/UiPath.CoreIpc/Server/Server.cs | 10 ++++ src/UiPath.CoreIpc/Wire/Dtos.cs | 46 ++++++++++++++++--- src/UiPath.CoreIpc/Wire/IpcJsonSerializer.cs | 2 +- 11 files changed, 116 insertions(+), 11 deletions(-) diff --git a/src/Clients/js/src/std/bcl/errors/CoreIpcError.ts b/src/Clients/js/src/std/bcl/errors/CoreIpcError.ts index a5321a04..7d21ea2a 100644 --- a/src/Clients/js/src/std/bcl/errors/CoreIpcError.ts +++ b/src/Clients/js/src/std/bcl/errors/CoreIpcError.ts @@ -3,4 +3,6 @@ export class CoreIpcError extends Error { super(message); this.name = 'CoreIpcError'; } + + data?: { [key: string]: any } } diff --git a/src/UiPath.CoreIpc.Tests/Services/ISystemService.cs b/src/UiPath.CoreIpc.Tests/Services/ISystemService.cs index 8fd915ee..f4a7cb4e 100644 --- a/src/UiPath.CoreIpc.Tests/Services/ISystemService.cs +++ b/src/UiPath.CoreIpc.Tests/Services/ISystemService.cs @@ -47,6 +47,7 @@ public interface ISystemService Task DanishNameOfDay(DayOfWeek day, CancellationToken ct); Task ReverseBytes(byte[] bytes, CancellationToken ct = default); + Task ThrowWithData(string serializedkey, object? serializedValue, string notSerializedKey); } public interface IUnregisteredCallback diff --git a/src/UiPath.CoreIpc.Tests/Services/SystemService.cs b/src/UiPath.CoreIpc.Tests/Services/SystemService.cs index 0c96445f..717e7a55 100644 --- a/src/UiPath.CoreIpc.Tests/Services/SystemService.cs +++ b/src/UiPath.CoreIpc.Tests/Services/SystemService.cs @@ -102,4 +102,13 @@ public Task ReverseBytes(byte[] bytes, CancellationToken ct = default) } return Task.FromResult(bytes); } + + public async Task ThrowWithData(string serializedkey, object? serializedValue, string notSerializedKey) + { + var ex = new NotImplementedException(); + ex.Data[serializedkey] = serializedValue; + ex.Data[notSerializedKey] = serializedValue; + await Task.Yield(); + throw ex; + } } diff --git a/src/UiPath.CoreIpc.Tests/SystemTests.cs b/src/UiPath.CoreIpc.Tests/SystemTests.cs index 70db74df..a75859dc 100644 --- a/src/UiPath.CoreIpc.Tests/SystemTests.cs +++ b/src/UiPath.CoreIpc.Tests/SystemTests.cs @@ -142,6 +142,51 @@ public async Task ServerCallingInexistentCallback_ShouldThrow() marshalledExceptionType.ShouldBe(typeof(EndpointNotFoundException).FullName); } +#if !NET461 //netframework only works with old style serializable types, so this won't work + + [Fact] + public async Task ExceptionDataIsMarshalledForObject() + => await ExceptionDataIsMarshalled(new ComplexNumber { I = 1, J = 2}); + + [Fact] + public async Task ExceptionDataIsMarshalledForArray() + => await ExceptionDataIsMarshalled(new string[] { "bla", "bla" }); + +#endif + + [Theory] + [InlineData("someString")] + [InlineData(2L)] + [InlineData(true)] + [InlineData(null)] + [InlineData(12.34d)] + public async Task ExceptionDataIsMarshalled(object? value) + { + const string notSerialized = "notSerializedKey"; + const string notSerialized2 = "notSerializedKey2"; + const string InlineDataKey = "somekey"; + const string OnErrorDataKey = "extraData"; + Error.SerializableDataKeys.Add(InlineDataKey); + Error.SerializableDataKeys.Add(OnErrorDataKey); + Error.SerializableDataKeys.Remove(notSerialized); + + _onError = (callInfo, ex) => + { + ex.Data.Add(OnErrorDataKey, value); + ex.Data.Add(notSerialized2, value); + var readValue = ex.Data[OnErrorDataKey]; + readValue.ShouldBe(value); + return ex; + }; + + var ex = await Proxy.ThrowWithData(InlineDataKey, value, notSerialized).ShouldThrowAsync(); + AsJtokenOrPrimitive(ex.Data[InlineDataKey]).ShouldBeEquivalentTo(AsJtokenOrPrimitive(value)); + ex.Data.Contains(notSerialized).ShouldBeFalse(); + AsJtokenOrPrimitive(ex.Data[OnErrorDataKey]).ShouldBeEquivalentTo(AsJtokenOrPrimitive(value)); + + object? AsJtokenOrPrimitive(object? value) => value is null || value.GetType().IsPrimitive ? value : Newtonsoft.Json.Linq.JToken.FromObject(value); + } + [Fact] public async Task ServerCallingInexistentCallback_ShouldThrow2() => await Proxy.AddIncrement(1, 2).ShouldThrowAsync() diff --git a/src/UiPath.CoreIpc.Tests/TestBase.cs b/src/UiPath.CoreIpc.Tests/TestBase.cs index d291af1e..07a13658 100644 --- a/src/UiPath.CoreIpc.Tests/TestBase.cs +++ b/src/UiPath.CoreIpc.Tests/TestBase.cs @@ -24,6 +24,7 @@ public abstract class TestBase : IAsyncLifetime protected readonly ConcurrentBag _serverBeforeCalls = new(); protected BeforeCallHandler? _tailBeforeCall = null; + protected Func? _onError; public TestBase(ITestOutputHelper outputHelper) { @@ -91,7 +92,8 @@ async Task Core() { _serverBeforeCalls.Add(callInfo); return _tailBeforeCall?.Invoke(callInfo, ct) ?? Task.CompletedTask; - } + }, + OnError = (callInfo, exception) => _onError?.Invoke(callInfo, exception) ?? exception }; return new() diff --git a/src/UiPath.CoreIpc/Helpers/Helpers.cs b/src/UiPath.CoreIpc/Helpers/Helpers.cs index abe5e0eb..9cd7f43f 100644 --- a/src/UiPath.CoreIpc/Helpers/Helpers.cs +++ b/src/UiPath.CoreIpc/Helpers/Helpers.cs @@ -13,8 +13,7 @@ namespace UiPath.Ipc; internal static class Helpers { internal const BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly; - internal static Error ToError(this Exception ex) => new(ex.Message, ex.StackTrace ?? ex.GetBaseException().StackTrace!, GetExceptionType(ex), ex.InnerException?.ToError()); - private static string GetExceptionType(Exception exception) => (exception as RemoteException)?.Type ?? exception.GetType().FullName!; + internal static Error ToError(this Exception ex) => Error.FromException(ex); internal static bool Enabled(this ILogger? logger, LogLevel logLevel = LogLevel.Information) => logger is not null && logger.IsEnabled(logLevel); [Conditional("DEBUG")] internal static void AssertDisposed(this SemaphoreSlim semaphore) => semaphore.AssertFieldNull("m_waitHandle"); diff --git a/src/UiPath.CoreIpc/Helpers/Router.cs b/src/UiPath.CoreIpc/Helpers/Router.cs index 9a023c46..e716b95e 100644 --- a/src/UiPath.CoreIpc/Helpers/Router.cs +++ b/src/UiPath.CoreIpc/Helpers/Router.cs @@ -134,6 +134,7 @@ public static Route From(IServiceProvider? serviceProvider, ContractSettings end BeforeCall = endpointSettings.BeforeIncomingCall, Scheduler = endpointSettings.Scheduler.OrDefault(), LoggerFactory = serviceProvider.MaybeCreateServiceFactory(), + OnError = endpointSettings.OnError, }; public required ServiceFactory Service { get; init; } @@ -141,4 +142,5 @@ public static Route From(IServiceProvider? serviceProvider, ContractSettings end public TaskScheduler Scheduler { get; init; } public BeforeCallHandler? BeforeCall { get; init; } public Func? LoggerFactory { get; init; } + public Func? OnError { get; init; } } diff --git a/src/UiPath.CoreIpc/Server/ContractSettings.cs b/src/UiPath.CoreIpc/Server/ContractSettings.cs index 45ba6eda..0045b686 100644 --- a/src/UiPath.CoreIpc/Server/ContractSettings.cs +++ b/src/UiPath.CoreIpc/Server/ContractSettings.cs @@ -7,11 +7,13 @@ public sealed class ContractSettings public TaskScheduler? Scheduler { get; set; } public BeforeCallHandler? BeforeIncomingCall { get; set; } internal ServiceFactory Service { get; } + public Func? OnError { get; set; } internal Type ContractType => Service.Type; internal object? ServiceInstance => Service.MaybeGetInstance(); internal IServiceProvider? ServiceProvider => Service.MaybeGetServiceProvider(); + public ContractSettings(Type contractType, object? serviceInstance = null) : this( serviceInstance is not null ? new ServiceFactory.Instance() @@ -40,5 +42,6 @@ internal ContractSettings(ContractSettings other) Scheduler = other.Scheduler; BeforeIncomingCall = other.BeforeIncomingCall; Service = other.Service; + OnError = other.OnError; } } diff --git a/src/UiPath.CoreIpc/Server/Server.cs b/src/UiPath.CoreIpc/Server/Server.cs index d1d8a45b..6263755f 100644 --- a/src/UiPath.CoreIpc/Server/Server.cs +++ b/src/UiPath.CoreIpc/Server/Server.cs @@ -102,6 +102,16 @@ private async ValueTask OnRequestReceived(Request request) } catch (Exception ex) when (response is null) { + try + { + ex = route.OnError?.Invoke(null, ex) ?? ex; + } + catch (Exception handlerEx) + { + ex = new AggregateException( + $"Error while handling error for {request}.", + ex, handlerEx); + } await OnError(request, timeoutHelper.CheckTimeout(ex, request.MethodName)); } finally diff --git a/src/UiPath.CoreIpc/Wire/Dtos.cs b/src/UiPath.CoreIpc/Wire/Dtos.cs index 21b77598..cfb29072 100644 --- a/src/UiPath.CoreIpc/Wire/Dtos.cs +++ b/src/UiPath.CoreIpc/Wire/Dtos.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace UiPath.Ipc; @@ -48,17 +49,40 @@ public TResult Deserialize() } } -public record Error(string Message, string StackTrace, string Type, Error? InnerError) +public record Error(string Message, string StackTrace, string Type, Error? InnerError, IReadOnlyDictionary? Data) { + public static readonly HashSet SerializableDataKeys = ["UiPath.ErrorInfo.Error"]; + [return: NotNullIfNotNull("exception")] public static Error? FromException(Exception? exception) - => exception is null - ? null + => exception is null + ? null : new( - Message: exception.Message, - StackTrace: exception.StackTrace ?? exception.GetBaseException().StackTrace!, - Type: GetExceptionType(exception), - InnerError: FromException(exception.InnerException)); + Message: exception.Message, + StackTrace: exception.StackTrace ?? exception.GetBaseException().StackTrace!, + Type: GetExceptionType(exception), + InnerError: FromException(exception.InnerException), + Data: GetExceptionData(exception)); + + private static IReadOnlyDictionary? GetExceptionData(Exception exception) + { + Dictionary? data = null; + foreach (var key in SerializableDataKeys) + { + if (exception.Data.Contains(key)) + { + data ??= []; + var value = exception.Data[key]; + data[key] = value switch + { + null or string or int or bool or Int64 or double or decimal or float => value, + _ => JToken.FromObject(value, IpcJsonSerializer.StringArgsSerializer) + }; + } + } + return data; + } + public override string ToString() => new RemoteException(this).ToString(); private static string GetExceptionType(Exception exception) => (exception as RemoteException)?.Type ?? exception.GetType().FullName!; @@ -70,6 +94,14 @@ public class RemoteException : Exception { Type = error.Type; StackTrace = error.StackTrace; + if (error.Data != null) + { + foreach (var key in error.Data) + { + var value = key.Value; + Data[key.Key] = value; + } + } } public string Type { get; } public override string StackTrace { get; } diff --git a/src/UiPath.CoreIpc/Wire/IpcJsonSerializer.cs b/src/UiPath.CoreIpc/Wire/IpcJsonSerializer.cs index ba1d75e0..2c6e55f7 100644 --- a/src/UiPath.CoreIpc/Wire/IpcJsonSerializer.cs +++ b/src/UiPath.CoreIpc/Wire/IpcJsonSerializer.cs @@ -9,7 +9,7 @@ internal class IpcJsonSerializer : IArrayPool { public static readonly IpcJsonSerializer Instance = new(); - static readonly JsonSerializer StringArgsSerializer = new() { CheckAdditionalContent = true }; + internal static readonly JsonSerializer StringArgsSerializer = new() { CheckAdditionalContent = true }; #if !NET461 [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]