diff --git a/src/ConductorSharp.Client/ConductorSharp.Client.csproj b/src/ConductorSharp.Client/ConductorSharp.Client.csproj index ff98cdd..8e698cc 100644 --- a/src/ConductorSharp.Client/ConductorSharp.Client.csproj +++ b/src/ConductorSharp.Client/ConductorSharp.Client.csproj @@ -6,7 +6,7 @@ Codaxy Codaxy ConductorSharp.Client - 4.3.0 + 4.4.0 Client library for Netflix Conductor, with some additional quality of life features. https://github.com/codaxy/conductor-sharp netflix;conductor diff --git a/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj b/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj index 79be75f..d091539 100644 --- a/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj +++ b/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj @@ -6,7 +6,7 @@ Codaxy Codaxy ConductorSharp.Engine - 4.3.0 + 4.4.0 Client library for Netflix Conductor, with some additional quality of life features. https://github.com/codaxy/conductor-sharp netflix;conductor diff --git a/src/ConductorSharp.Engine/Extensions/ConductorSharpBuilder.cs b/src/ConductorSharp.Engine/Extensions/ConductorSharpBuilder.cs index 912acb5..6c1b670 100644 --- a/src/ConductorSharp.Engine/Extensions/ConductorSharpBuilder.cs +++ b/src/ConductorSharp.Engine/Extensions/ConductorSharpBuilder.cs @@ -49,6 +49,8 @@ params Assembly[] handlerAssemblies Builder.AddScoped(); + Builder.AddTransient(); + Builder.AddSingleton(); Builder.AddTransient(); diff --git a/src/ConductorSharp.Engine/Model/StructuredError.cs b/src/ConductorSharp.Engine/Model/StructuredError.cs index b712a37..6049751 100644 --- a/src/ConductorSharp.Engine/Model/StructuredError.cs +++ b/src/ConductorSharp.Engine/Model/StructuredError.cs @@ -12,6 +12,12 @@ public class StructuredError /// Current structured-error payload shape version. public const int CurrentVersion = 1; + /// + /// Reserved classification code for failures that declared no structured error. Producers must not + /// use it for declared errors; consumers may treat it as "no classification available". + /// + public const string UnclassifiedCode = "UNCLASSIFIED"; + /// Stable, opaque classification code (e.g. an implementation-defined code, or UNCLASSIFIED). public string Code { get; set; } diff --git a/src/ConductorSharp.Engine/Util/FailedTaskStructuredErrorReader.cs b/src/ConductorSharp.Engine/Util/FailedTaskStructuredErrorReader.cs new file mode 100644 index 0000000..d7af8cc --- /dev/null +++ b/src/ConductorSharp.Engine/Util/FailedTaskStructuredErrorReader.cs @@ -0,0 +1,136 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ConductorSharp.Client.Service; +using ConductorSharp.Engine.Model; +using TaskStatus = ConductorSharp.Client.Generated.TaskStatus; + +namespace ConductorSharp.Engine.Util +{ + /// + /// Walks a failed workflow execution through sub-workflows to the deepest failed task and reads the + /// structured error it declared via the shared structured_error task-output contract + /// (). + /// + /// This is the single reader for the contract: consumers that harvest a failure classification out of an + /// execution (failure workflows, notification builders, drill-downs) should use it instead of re-implementing + /// the descent, so they cannot drift apart on which task "the" failure is. + /// + /// + public class FailedTaskStructuredErrorReader + { + private readonly IWorkflowService _workflowService; + + public FailedTaskStructuredErrorReader(IWorkflowService workflowService) + { + _workflowService = workflowService; + } + + /// + /// Reads the structured error declared by the deepest failed task of , + /// following the Try pattern: returns false (with a null ) when + /// the execution has no failed task or the failed task declared nothing. Synchronous — an out + /// parameter rules out async — so the underlying Conductor call blocks the calling thread. + /// + public bool TryRead(string workflowId, out StructuredError error, CancellationToken cancellationToken) + { + var failed = FindDeepestFailedTaskAsync(workflowId, cancellationToken).GetAwaiter().GetResult(); + + if (failed?.OutputData != null && StructuredErrorSerializer.TryDeserialize(failed.OutputData, out var structured)) + { + error = structured; + return true; + } + + error = null; + return false; + } + + /// + /// Like , but always yields an error: a failure that declared nothing is + /// classified as with as the + /// sanitized reason, so raw internals never cross a boundary by default. The returned + /// always carries the most specific diagnostic available: the declared + /// message when there is one, otherwise an internal locator (workflow id, task id, reference name, raw + /// reason) suitable for drill-down. + /// + /// Execution to walk. + /// + /// Sanitized reason used when the failure declared no reason of its own. Callers own this text — it is + /// what crosses their boundary. + /// + /// + /// Optional raw failure reason already known to the caller (e.g. the failure workflow's input); used only + /// inside the diagnostic message, never as the sanitized reason. + /// + public async Task ReadOrFallbackAsync( + string workflowId, + string genericReason, + string fallbackReason, + CancellationToken cancellationToken + ) + { + var failed = await FindDeepestFailedTaskAsync(workflowId, cancellationToken); + + if (failed?.OutputData != null && StructuredErrorSerializer.TryDeserialize(failed.OutputData, out var structured)) + { + return new StructuredError + { + Code = structured.Code, + Reason = string.IsNullOrEmpty(structured.Reason) ? genericReason : structured.Reason, + Message = string.IsNullOrEmpty(structured.Message) ? BuildDiagnosticMessage(failed, fallbackReason) : structured.Message, + ReferenceError = structured.ReferenceError, + Version = structured.Version + }; + } + + return new StructuredError + { + Code = StructuredError.UnclassifiedCode, + Reason = genericReason, + Message = BuildDiagnosticMessage(failed, fallbackReason) + }; + } + + /// + /// Walks the execution to the task that actually failed. A phase often runs as a SUB_WORKFLOW inside a + /// dynamic fork, and the aggregating JOIN task is frequently the last failed task in the parent — + /// so any failed sub-workflow is descended into first (walking from the last), and FORK/JOIN aggregators + /// are skipped when picking a leaf. Returns null when the execution has no failed task. + /// + public async Task FindDeepestFailedTaskAsync(string workflowId, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(workflowId)) + return null; + + var workflow = await _workflowService.GetExecutionStatusAsync(workflowId, true, cancellationToken); + + var failedTasks = (workflow.Tasks ?? []).Where(t => t.Status is TaskStatus.FAILED or TaskStatus.FAILED_WITH_TERMINAL_ERROR).ToList(); + + if (failedTasks.Count == 0) + return null; + + for (var i = failedTasks.Count - 1; i >= 0; i--) + { + var subWorkflowId = failedTasks[i].SubWorkflowId; + if (!string.IsNullOrEmpty(subWorkflowId)) + { + var deeper = await FindDeepestFailedTaskAsync(subWorkflowId, cancellationToken); + if (deeper != null) + return deeper; + } + } + + return failedTasks.LastOrDefault(t => t.TaskType is not ("JOIN" or "FORK")) ?? failedTasks[^1]; + } + + private static string BuildDiagnosticMessage(ConductorSharp.Client.Generated.Task task, string fallbackReason) + { + if (task == null) + return fallbackReason ?? "No failed task found."; + + var raw = task.ReasonForIncompletion ?? fallbackReason; + return $"workflowId={task.WorkflowInstanceId}; taskId={task.TaskId}; ref={task.ReferenceTaskName}; reason={raw}"; + } + } +} diff --git a/src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs b/src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs index c640c95..93c9761 100644 --- a/src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs +++ b/src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs @@ -6,16 +6,15 @@ namespace ConductorSharp.Engine.Util { /// - /// Defines the structured_error task-output contract (key + shape) and the read side used by all consumers - /// (). There are two producers, both emitting the same shape because they serialize the same - /// type with the same serializer settings: - /// - /// the execution-manager catch block, which serializes when a - /// worker throws a ; and - /// external signal senders (which have no exception to catch), which render via . - /// + /// Defines the structured_error task-output contract (key + shape): the write side + /// () and the tolerant read side used by all consumers (). + /// The execution-manager catch block emits the same shape without this class, by serializing + /// with the same serializer settings when a worker throws a + /// . Internal on purpose: consumers + /// read the contract through , and producers outside this + /// assembly declare errors by throwing, not by rendering the payload themselves. /// - public static class StructuredErrorSerializer + internal static class StructuredErrorSerializer { /// Well-known task-output key carrying the structured error payload. public const string OutputKey = "structured_error"; @@ -24,19 +23,15 @@ public static class StructuredErrorSerializer /// Renders a to an output-data fragment ({ "structured_error": { ... } }) /// using the standard snake_case IO serializer settings. Returns an empty dictionary for a null error. /// - public static IDictionary ToOutputData(StructuredError error) + public static IDictionary Serialize(StructuredError error) { if (error == null) return new Dictionary(); - return new Dictionary { [OutputKey] = ToOutputValue(error) }; - } - - /// Renders just the value placed under , in the canonical snake_case shape. - public static object ToOutputValue(StructuredError error) - { var json = JsonConvert.SerializeObject(error, ConductorConstants.IoJsonSerializerSettings); - return JsonConvert.DeserializeObject>(json, ConductorConstants.IoJsonSerializerSettings); + var value = JsonConvert.DeserializeObject>(json, ConductorConstants.IoJsonSerializerSettings); + + return new Dictionary { [OutputKey] = value }; } /// @@ -45,7 +40,7 @@ public static object ToOutputValue(StructuredError error) /// payload returns false and never throws, so a parse problem degrades error quality (falling back to /// the generic path) rather than failing the failure workflow. /// - public static bool TryParse(IDictionary taskOutput, out StructuredError error) + public static bool TryDeserialize(IDictionary taskOutput, out StructuredError error) { error = null; diff --git a/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj b/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj index 65f9208..c7033c9 100644 --- a/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj +++ b/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj @@ -4,7 +4,7 @@ net6.0 enable enable - 4.3.0 + 4.4.0 Codaxy Codaxy diff --git a/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj b/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj index c93109d..7e05ca5 100644 --- a/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj +++ b/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj @@ -7,7 +7,7 @@ False Codaxy Codaxy - 4.3.0 + 4.4.0 diff --git a/src/ConductorSharp.Patterns/Extensions/ContainerBuilderExtensions.cs b/src/ConductorSharp.Patterns/Extensions/ContainerBuilderExtensions.cs index 9535c46..cfd2a94 100644 --- a/src/ConductorSharp.Patterns/Extensions/ContainerBuilderExtensions.cs +++ b/src/ConductorSharp.Patterns/Extensions/ContainerBuilderExtensions.cs @@ -15,6 +15,7 @@ public static IExecutionManagerBuilder AddConductorSharpPatterns(this IExecution { executionManagerBuilder.Builder.RegisterWorkerTask(); executionManagerBuilder.Builder.RegisterWorkerTask(); + executionManagerBuilder.Builder.RegisterWorkerTask(); executionManagerBuilder.Builder.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(WaitSeconds).Assembly)); return executionManagerBuilder; diff --git a/src/ConductorSharp.Patterns/Tasks/BuildFailureError.cs b/src/ConductorSharp.Patterns/Tasks/BuildFailureError.cs new file mode 100644 index 0000000..cd1a318 --- /dev/null +++ b/src/ConductorSharp.Patterns/Tasks/BuildFailureError.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ConductorSharp.Engine; +using ConductorSharp.Engine.Builders.Metadata; +using ConductorSharp.Engine.Model; +using ConductorSharp.Engine.Util; +using MediatR; + +namespace ConductorSharp.Patterns.Tasks +{ + #region models + public class BuildFailureErrorRequest : IRequest + { + /// + /// Id of the failed workflow execution to classify (typically the failure workflow's workflowId input). + /// + public string? WorkflowId { get; set; } + + /// + /// Sanitized reason used when the failure declared no reason of its own. This is the text that crosses the + /// caller's boundary — supply domain wording here; defaults to a neutral generic. + /// + public string? GenericReason { get; set; } + + /// + /// Optional raw failure reason already known to the caller (e.g. the failure workflow's reason input); + /// used only inside the diagnostic message, never as the sanitized reason. + /// + public string? FallbackReason { get; set; } + } + + public record BuildFailureErrorResponse(StructuredError Error); + + #endregion + + /// + /// Walks the given failed execution to its deepest failed task (via + /// ) and returns the structured error it declared — or the + /// fallback with the sanitized GenericReason when it + /// declared nothing. Intended for failure workflows that persist a failure classification atomically with the + /// failed state. + /// + /// Registered by AddConductorSharpPatterns under the shared task name. The task is a stateless, + /// read-only Conductor API lookup, so on a shared cluster it is safe for multiple services to register + /// and poll the same queue — whichever picks the task up produces the same result. Use Conductor task + /// domains if poller isolation is ever required. + /// + /// + [OriginalName(Constants.TaskNamePrefix + "_build_failure_error")] + public class BuildFailureError(FailedTaskStructuredErrorReader errorReader) + : TaskRequestHandler + { + public const string DefaultGenericReason = "The workflow failed."; + + private readonly FailedTaskStructuredErrorReader _errorReader = errorReader; + + public override async Task Handle(BuildFailureErrorRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(request.WorkflowId)) + throw new Exception("No workflowId provided"); + + var error = await _errorReader.ReadOrFallbackAsync( + request.WorkflowId, + string.IsNullOrEmpty(request.GenericReason) ? DefaultGenericReason : request.GenericReason, + request.FallbackReason, + cancellationToken + ); + + return new BuildFailureErrorResponse(error); + } + } +} diff --git a/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj b/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj index 8ccaa36..1359741 100644 --- a/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj +++ b/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj @@ -7,7 +7,7 @@ disable true dotnet-conductorsharp - 4.3.0 + 4.4.0 diff --git a/test/ConductorSharp.Engine.Tests/Unit/FailedTaskStructuredErrorReaderTests.cs b/test/ConductorSharp.Engine.Tests/Unit/FailedTaskStructuredErrorReaderTests.cs new file mode 100644 index 0000000..97cb24e --- /dev/null +++ b/test/ConductorSharp.Engine.Tests/Unit/FailedTaskStructuredErrorReaderTests.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using ConductorSharp.Client.Generated; +using ConductorSharp.Client.Service; +using ConductorSharp.Engine.Model; +using ConductorSharp.Engine.Util; +using Xunit; +using GeneratedTask = ConductorSharp.Client.Generated.Task; +using Task = System.Threading.Tasks.Task; +using TaskStatus = ConductorSharp.Client.Generated.TaskStatus; + +namespace ConductorSharp.Engine.Tests.Unit +{ + public class FailedTaskStructuredErrorReaderTests + { + private const string GenericReason = "The request could not be completed."; + + // Serves executions from an in-memory map so the descent through sub-workflows can be exercised + // without a Conductor server. Only GetExecutionStatusAsync is meaningful. + private sealed class FakeWorkflowService(Dictionary executions) : IWorkflowService + { + public Task GetExecutionStatusAsync( + string workflowId, + bool? includeTasks = false, + CancellationToken cancellationToken = default + ) => Task.FromResult(executions[workflowId]); + + public Task DecideAsync(string workflowId, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + public Task DeleteAsync(string workflowId, bool? archiveWorkflow = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task>> GetCorrelatedAsync( + string name, + IEnumerable correlationIds, + bool? includeClosed = false, + bool? includeTasks = false, + CancellationToken cancellationToken = default + ) => throw new NotImplementedException(); + + public Task> ListCorrelatedAsync( + string name, + string correlationId, + bool? includeClosed = false, + bool? includeTasks = false, + CancellationToken cancellationToken = default + ) => throw new NotImplementedException(); + + public Task GetExternalStorageLocationAsync( + string path, + string operation, + string payloadType, + CancellationToken cancellationToken = default + ) => throw new NotImplementedException(); + + public Task> ListRunningAsync( + string name, + int? version, + long? startTime = null, + long? endTime = null, + CancellationToken cancellationToken = default + ) => throw new NotImplementedException(); + + public Task PauseAsync(string workflowId, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + public Task RerunAsync( + string workflowId, + RerunWorkflowRequest rerunWorkflowRequest, + CancellationToken cancellationToken = default + ) => throw new NotImplementedException(); + + public Task ResetCallbacksAsync(string workflowId, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + public Task RestartAsync(string workflowId, bool? useLatestDefinitions = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task ResumeAsync(string workflowId, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + public Task RetryAsync(string workflowId, bool? resumeSubworkflowTasks = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task SearchAsync( + int? start = null, + int? size = null, + string sort = null, + string freeText = null, + string query = null, + CancellationToken cancellationToken = default + ) => throw new NotImplementedException(); + + public Task SearchByTasksAsync( + int? start = null, + int? size = null, + string sort = null, + string freeText = null, + string query = null, + CancellationToken cancellationToken = default + ) => throw new NotImplementedException(); + + public Task SearchV2Async( + int? start = null, + int? size = null, + string sort = null, + string freeText = null, + string query = null, + CancellationToken cancellationToken = default + ) => throw new NotImplementedException(); + + public Task SearchV2ByTasksAsync( + int? start = null, + int? size = null, + string sort = null, + string freeText = null, + string query = null, + CancellationToken cancellationToken = default + ) => throw new NotImplementedException(); + + public Task SkipTaskAsync( + string workflowId, + string taskReferenceName, + SkipTaskRequest skipTaskRequest, + CancellationToken cancellationToken = default + ) => throw new NotImplementedException(); + + public Task StartAsync(StartWorkflowRequest startWorkflowRequest, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task TerminateAsync(string workflowId, string reason = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task TestAsync(WorkflowTestRequest workflowTestRequest, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + + private static FailedTaskStructuredErrorReader Reader(Dictionary executions) => new(new FakeWorkflowService(executions)); + + private static GeneratedTask FailedTask( + string taskType = "SIMPLE", + string subWorkflowId = null, + IDictionary outputData = null, + string reason = null, + string taskId = null, + string reference = null, + string workflowInstanceId = null + ) => + new() + { + Status = TaskStatus.FAILED, + TaskType = taskType, + SubWorkflowId = subWorkflowId, + OutputData = outputData, + ReasonForIncompletion = reason, + TaskId = taskId, + ReferenceTaskName = reference, + WorkflowInstanceId = workflowInstanceId + }; + + private static Workflow Execution(params GeneratedTask[] tasks) => new() { Tasks = tasks }; + + [Fact] + public void TryRead_returns_the_declared_structured_error() + { + var output = StructuredErrorSerializer.Serialize(new StructuredError { Code = "RESOURCE_UNAVAILABLE", Reason = "No port available" }); + var reader = Reader(new() { ["wf"] = Execution(FailedTask(outputData: output)) }); + + var found = reader.TryRead("wf", out var error, CancellationToken.None); + + Assert.True(found); + Assert.Equal("RESOURCE_UNAVAILABLE", error.Code); + Assert.Equal("No port available", error.Reason); + } + + [Fact] + public void TryRead_returns_false_when_the_failed_task_declared_nothing() + { + var reader = Reader(new() { ["wf"] = Execution(FailedTask(reason: "raw internals")) }); + + var found = reader.TryRead("wf", out var error, CancellationToken.None); + + Assert.False(found); + Assert.Null(error); + } + + [Fact] + public void TryRead_returns_false_when_nothing_failed() + { + var reader = Reader(new() { ["wf"] = Execution() }); + + var found = reader.TryRead("wf", out var error, CancellationToken.None); + + Assert.False(found); + Assert.Null(error); + } + + [Fact] + public void Descends_into_the_failed_sub_workflow_instead_of_stopping_on_the_join() + { + // Parent: a failed SUB_WORKFLOW and the aggregating JOIN that failed after it. The declared error + // lives on the leaf task inside the child execution. + var output = StructuredErrorSerializer.Serialize(new StructuredError { Code = "LEAF", Reason = "leaf failed" }); + var reader = Reader( + new() + { + ["parent"] = Execution(FailedTask(taskType: "SUB_WORKFLOW", subWorkflowId: "child"), FailedTask(taskType: "JOIN")), + ["child"] = Execution(FailedTask(outputData: output)) + } + ); + + var found = reader.TryRead("parent", out var error, CancellationToken.None); + + Assert.True(found); + Assert.Equal("LEAF", error.Code); + } + + [Fact] + public void Skips_fork_and_join_aggregators_when_picking_the_leaf() + { + var output = StructuredErrorSerializer.Serialize(new StructuredError { Code = "SIMPLE_LEAF", Reason = "r" }); + var reader = Reader( + new() { ["wf"] = Execution(FailedTask(taskType: "FORK"), FailedTask(outputData: output), FailedTask(taskType: "JOIN")) } + ); + + var found = reader.TryRead("wf", out var error, CancellationToken.None); + + Assert.True(found); + Assert.Equal("SIMPLE_LEAF", error.Code); + } + + [Fact] + public async Task ReadOrFallback_falls_back_to_unclassified_with_the_sanitized_generic_reason() + { + var reader = Reader( + new() { ["wf"] = Execution(FailedTask(reason: "raw stack trace", taskId: "t1", reference: "ref1", workflowInstanceId: "wf")) } + ); + + var error = await reader.ReadOrFallbackAsync("wf", GenericReason, fallbackReason: null, CancellationToken.None); + + Assert.Equal(StructuredError.UnclassifiedCode, error.Code); + Assert.Equal(GenericReason, error.Reason); + // Raw internals go into the diagnostic message for drill-down, never into the reason. + Assert.Contains("taskId=t1", error.Message); + Assert.Contains("raw stack trace", error.Message); + } + + [Fact] + public async Task ReadOrFallback_keeps_the_declared_message_and_fills_a_diagnostic_one_when_absent() + { + var withMessage = StructuredErrorSerializer.Serialize( + new StructuredError + { + Code = "C", + Reason = "r", + Message = "declared detail" + } + ); + var withoutMessage = new StructuredError { Code = "C", Reason = "r" }; + withoutMessage.Message = null; + var reader = Reader( + new() + { + ["with"] = Execution(FailedTask(outputData: withMessage)), + ["without"] = Execution(FailedTask(outputData: StructuredErrorSerializer.Serialize(withoutMessage), taskId: "t9")) + } + ); + + Assert.Equal("declared detail", (await reader.ReadOrFallbackAsync("with", GenericReason, null, CancellationToken.None)).Message); + Assert.Contains("taskId=t9", (await reader.ReadOrFallbackAsync("without", GenericReason, null, CancellationToken.None)).Message); + } + + [Fact] + public async Task ReadOrFallback_with_no_workflow_id_still_returns_a_classification() + { + var reader = Reader(new()); + + var error = await reader.ReadOrFallbackAsync(null, GenericReason, "reason from failure workflow input", CancellationToken.None); + + Assert.Equal(StructuredError.UnclassifiedCode, error.Code); + Assert.Equal(GenericReason, error.Reason); + Assert.Equal("reason from failure workflow input", error.Message); + } + } +} diff --git a/test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs b/test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs index 83a710a..925450f 100644 --- a/test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs +++ b/test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs @@ -114,7 +114,7 @@ public void Message_survives_the_round_trip() new StructuredErrorException("CODE", "Short reason", "https://example.org/entity/9", "Long diagnostic detail") ); - Assert.True(StructuredErrorSerializer.TryParse(dict, out var parsed)); + Assert.True(StructuredErrorSerializer.TryDeserialize(dict, out var parsed)); Assert.Equal("CODE", parsed.Code); Assert.Equal("Short reason", parsed.Reason); Assert.Equal("Long diagnostic detail", parsed.Message); @@ -139,9 +139,9 @@ public void RoundTrip_helper_output_is_parsed_back() ReferenceError = "https://example.org/entity/7" }; - var outputData = StructuredErrorSerializer.ToOutputData(error); + var outputData = StructuredErrorSerializer.Serialize(error); - Assert.True(StructuredErrorSerializer.TryParse(outputData, out var parsed)); + Assert.True(StructuredErrorSerializer.TryDeserialize(outputData, out var parsed)); Assert.Equal(error.Code, parsed.Code); Assert.Equal(error.Reason, parsed.Reason); Assert.Equal(error.Message, parsed.Message); @@ -154,7 +154,7 @@ public void RoundTrip_catch_block_output_is_parsed_back() { var dict = SerializeCatchOutput(new StructuredErrorException("RESOURCE_UNAVAILABLE", "No port available")); - Assert.True(StructuredErrorSerializer.TryParse(dict, out var parsed)); + Assert.True(StructuredErrorSerializer.TryDeserialize(dict, out var parsed)); Assert.Equal("RESOURCE_UNAVAILABLE", parsed.Code); Assert.Equal("No port available", parsed.Reason); } @@ -164,14 +164,14 @@ public void TryParse_returns_false_when_key_absent() { var dict = new Dictionary { ["error_message"] = "boom" }; - Assert.False(StructuredErrorSerializer.TryParse(dict, out var parsed)); + Assert.False(StructuredErrorSerializer.TryDeserialize(dict, out var parsed)); Assert.Null(parsed); } [Fact] public void TryParse_returns_false_on_null_input() { - Assert.False(StructuredErrorSerializer.TryParse(null, out var parsed)); + Assert.False(StructuredErrorSerializer.TryDeserialize(null, out var parsed)); Assert.Null(parsed); } @@ -180,7 +180,7 @@ public void TryParse_returns_false_on_malformed_payload() { var dict = new Dictionary { [StructuredErrorSerializer.OutputKey] = "not-a-structured-error" }; - Assert.False(StructuredErrorSerializer.TryParse(dict, out _)); + Assert.False(StructuredErrorSerializer.TryDeserialize(dict, out _)); } [Fact] @@ -191,7 +191,7 @@ public void TryParse_returns_false_when_code_missing() [StructuredErrorSerializer.OutputKey] = new Dictionary { ["reason"] = "no code here" } }; - Assert.False(StructuredErrorSerializer.TryParse(dict, out _)); + Assert.False(StructuredErrorSerializer.TryDeserialize(dict, out _)); } [Fact] @@ -203,7 +203,7 @@ public void TryParse_tolerates_a_message_only_payload_by_degrading() [StructuredErrorSerializer.OutputKey] = new Dictionary { ["message"] = "detail but no code" } }; - Assert.False(StructuredErrorSerializer.TryParse(dict, out _)); + Assert.False(StructuredErrorSerializer.TryDeserialize(dict, out _)); } } }