From 3596faaf714d0c4f7ed2e8686b64fe4ef78277d3 Mon Sep 17 00:00:00 2001 From: andrejsimic Date: Tue, 21 Jul 2026 09:40:11 +0200 Subject: [PATCH 1/3] CxODEV-1730: port StructuredErrorException + structured_error contract to master MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the structured-error capability released on the v3 line (v3.8.0) to the v4 master line. Same behavior, no version bump — master keeps its 4.0.1 version. - StructuredErrorException(code, reason, referenceError?) - StructuredError DTO and ErrorOutput.StructuredError (omitted when null → back-compat) - StructuredErrorSerializer: OutputKey, ToOutputData (for signal senders) and tolerant TryParse (consumer side); round-trip contract test pins shape/key - both ExecutionManager and TypePollSpreadingExecutionManager populate structured_error when a StructuredErrorException is caught; plain exceptions still emit only error_message Co-Authored-By: Claude Opus 4.8 --- .../Exceptions/StructuredErrorException.cs | 40 ++++++ src/ConductorSharp.Engine/ExecutionManager.cs | 13 ++ .../Model/ErrorOutput.cs | 7 + .../Model/StructuredError.cs | 24 ++++ .../TypePollSpreadingExecutionManager.cs | 13 ++ .../Util/StructuredErrorSerializer.cs | 75 +++++++++++ .../Unit/StructuredErrorTests.cs | 127 ++++++++++++++++++ 7 files changed, 299 insertions(+) create mode 100644 src/ConductorSharp.Engine/Exceptions/StructuredErrorException.cs create mode 100644 src/ConductorSharp.Engine/Model/StructuredError.cs create mode 100644 src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs create mode 100644 test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs diff --git a/src/ConductorSharp.Engine/Exceptions/StructuredErrorException.cs b/src/ConductorSharp.Engine/Exceptions/StructuredErrorException.cs new file mode 100644 index 00000000..86d1dd92 --- /dev/null +++ b/src/ConductorSharp.Engine/Exceptions/StructuredErrorException.cs @@ -0,0 +1,40 @@ +using System; + +namespace ConductorSharp.Engine.Exceptions +{ + /// + /// Thrown by a worker to attach a structured, sanitized error classification to the failed task's output. + /// When caught by the execution manager, the // + /// are serialized under the structured_error output key (see + /// ), in addition to the plain + /// error_message, so downstream consumers can read a stable classification without parsing free-text + /// reasons. Plain exceptions are unaffected and keep producing only error_message. + /// + public class StructuredErrorException : Exception + { + /// Stable, opaque classification code. Consumers map this to a failure response. + public string Code { get; } + + /// Human-readable, sanitized reason. Safe to surface across a layer boundary. + public string Reason { get; } + + /// Optional URI pointing at the entity where the failure originated (drill-down link). + public string ReferenceError { get; } + + public StructuredErrorException(string code, string reason, string referenceError = null) + : base(reason) + { + Code = code; + Reason = reason; + ReferenceError = referenceError; + } + + public StructuredErrorException(string code, string reason, string referenceError, Exception innerException) + : base(reason, innerException) + { + Code = code; + Reason = reason; + ReferenceError = referenceError; + } + } +} diff --git a/src/ConductorSharp.Engine/ExecutionManager.cs b/src/ConductorSharp.Engine/ExecutionManager.cs index 5288c5b4..2ef451cf 100644 --- a/src/ConductorSharp.Engine/ExecutionManager.cs +++ b/src/ConductorSharp.Engine/ExecutionManager.cs @@ -8,6 +8,7 @@ using ConductorSharp.Client.Generated; using ConductorSharp.Client.Service; using ConductorSharp.Client.Util; +using ConductorSharp.Engine.Exceptions; using ConductorSharp.Engine.Interface; using ConductorSharp.Engine.Model; using ConductorSharp.Engine.Polling; @@ -248,6 +249,18 @@ await _taskManager.UpdateAsync( var errorMessage = new ErrorOutput { ErrorMessage = exception.Message }; + // A worker may throw a StructuredErrorException to attach a sanitized, stable classification to the + // failed task's output. Plain exceptions keep producing only error_message, preserving backward compatibility. + if (exception is StructuredErrorException structuredException) + { + errorMessage.StructuredError = new StructuredError + { + Code = structuredException.Code, + Reason = structuredException.Reason, + ReferenceError = structuredException.ReferenceError + }; + } + // TODO: We should verify that this is alright, it is possible that when executed concurrently, // the updates caused by LogAsync will be discarded because the call of UpdateAsync(TaskResult...) // sets the logs to null. Not sure how this is implemented in the backend, also, would have expected this to be a diff --git a/src/ConductorSharp.Engine/Model/ErrorOutput.cs b/src/ConductorSharp.Engine/Model/ErrorOutput.cs index add9e806..9fb4096e 100644 --- a/src/ConductorSharp.Engine/Model/ErrorOutput.cs +++ b/src/ConductorSharp.Engine/Model/ErrorOutput.cs @@ -7,5 +7,12 @@ namespace ConductorSharp.Engine.Model public class ErrorOutput { public string ErrorMessage { get; set; } + + /// + /// Optional structured error classification. Null for plain (unclassified) failures, in which case it is + /// omitted from serialized output (NullValueHandling.Ignore), preserving backward compatibility with + /// consumers that only read . + /// + public StructuredError StructuredError { get; set; } } } diff --git a/src/ConductorSharp.Engine/Model/StructuredError.cs b/src/ConductorSharp.Engine/Model/StructuredError.cs new file mode 100644 index 00000000..1457b0ee --- /dev/null +++ b/src/ConductorSharp.Engine/Model/StructuredError.cs @@ -0,0 +1,24 @@ +namespace ConductorSharp.Engine.Model +{ + /// + /// Sanitized, structured error classification transported across the structured_error task-output key. + /// The field shape is versioned via so it can evolve without silent misparses. + /// + public class StructuredError + { + /// Current structured-error payload shape version. + public const int CurrentVersion = 1; + + /// Stable, opaque classification code (e.g. an implementation-defined code, or UNCLASSIFIED). + public string Code { get; set; } + + /// Human-readable, sanitized reason. + public string Reason { get; set; } + + /// Optional URI pointing at the entity where the failure originated (drill-down link). + public string ReferenceError { get; set; } + + /// Payload shape version marker. Defaults to . + public int Version { get; set; } = CurrentVersion; + } +} diff --git a/src/ConductorSharp.Engine/TypePollSpreadingExecutionManager.cs b/src/ConductorSharp.Engine/TypePollSpreadingExecutionManager.cs index 9c18eb52..6fcab408 100644 --- a/src/ConductorSharp.Engine/TypePollSpreadingExecutionManager.cs +++ b/src/ConductorSharp.Engine/TypePollSpreadingExecutionManager.cs @@ -8,6 +8,7 @@ using ConductorSharp.Client.Generated; using ConductorSharp.Client.Service; using ConductorSharp.Client.Util; +using ConductorSharp.Engine.Exceptions; using ConductorSharp.Engine.Interface; using ConductorSharp.Engine.Model; using ConductorSharp.Engine.Polling; @@ -257,6 +258,18 @@ await _taskManager.UpdateAsync( var errorMessage = new ErrorOutput { ErrorMessage = exception.Message }; + // A worker may throw a StructuredErrorException to attach a sanitized, stable classification to the + // failed task's output. Plain exceptions keep producing only error_message, preserving backward compatibility. + if (exception is StructuredErrorException structuredException) + { + errorMessage.StructuredError = new StructuredError + { + Code = structuredException.Code, + Reason = structuredException.Reason, + ReferenceError = structuredException.ReferenceError + }; + } + // TODO: We should verify that this is alright, it is possible that when executed concurrently, // the updates caused by LogAsync will be discarded because the call of UpdateAsync(TaskResult...) // sets the logs to null. Not sure how this is implemented in the backend, also, would have expected this to be a diff --git a/src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs b/src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs new file mode 100644 index 00000000..c640c957 --- /dev/null +++ b/src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using ConductorSharp.Client; +using ConductorSharp.Engine.Model; +using Newtonsoft.Json; + +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 . + /// + /// + public static class StructuredErrorSerializer + { + /// Well-known task-output key carrying the structured error payload. + public const string OutputKey = "structured_error"; + + /// + /// 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) + { + 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); + } + + /// + /// Tolerantly extracts a from a failed task's output data. Presence-checks the + /// single and deserializes only that subtree. A missing, malformed, or code-less + /// 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) + { + error = null; + + if (taskOutput == null || !taskOutput.TryGetValue(OutputKey, out var raw) || raw == null) + return false; + + try + { + // raw may be a JObject (Newtonsoft round-trip), a nested dictionary, or a raw JSON string. + var json = raw is string s ? s : JsonConvert.SerializeObject(raw, ConductorConstants.IoJsonSerializerSettings); + var parsed = JsonConvert.DeserializeObject(json, ConductorConstants.IoJsonSerializerSettings); + + // A structured error is only meaningful with a classification code; anything else is treated as + // unstructured and degraded to the generic fallback by the caller. + if (parsed == null || string.IsNullOrEmpty(parsed.Code)) + return false; + + error = parsed; + return true; + } + catch (JsonException) + { + return false; + } + } + } +} diff --git a/test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs b/test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs new file mode 100644 index 00000000..6fd7856e --- /dev/null +++ b/test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using ConductorSharp.Client; +using ConductorSharp.Client.Util; +using ConductorSharp.Engine.Exceptions; +using ConductorSharp.Engine.Model; +using ConductorSharp.Engine.Util; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace ConductorSharp.Engine.Tests.Unit +{ + public class StructuredErrorTests + { + // Mirrors the execution-manager catch block: builds the ErrorOutput (setting StructuredError for a + // StructuredErrorException) and serializes it. TryParse below asserts this output round-trips through the + // shared serializer, pinning the property-derived key/shape to StructuredErrorSerializer.OutputKey. + private static IDictionary SerializeCatchOutput(System.Exception exception) + { + var output = new ErrorOutput { ErrorMessage = exception.Message }; + + if (exception is StructuredErrorException structuredException) + { + output.StructuredError = new StructuredError + { + Code = structuredException.Code, + Reason = structuredException.Reason, + ReferenceError = structuredException.ReferenceError + }; + } + + return SerializationHelper.ObjectToDictionary(output, ConductorConstants.IoJsonSerializerSettings); + } + + [Fact] + public void StructuredErrorException_produces_snake_case_structured_error() + { + var exception = new StructuredErrorException("RESOURCE_UNAVAILABLE", "No port available", "https://rom/resourceOrder/42"); + + var dict = SerializeCatchOutput(exception); + + Assert.True(dict.ContainsKey("error_message")); + Assert.True(dict.ContainsKey(StructuredErrorSerializer.OutputKey)); + + var structured = JObject.Parse(JsonConvert.SerializeObject(dict))["structured_error"]; + Assert.Equal("RESOURCE_UNAVAILABLE", (string)structured["code"]); + Assert.Equal("No port available", (string)structured["reason"]); + Assert.Equal("https://rom/resourceOrder/42", (string)structured["reference_error"]); + Assert.Equal(StructuredError.CurrentVersion, (int)structured["version"]); + } + + [Fact] + public void PlainException_output_is_backward_compatible() + { + var dict = SerializeCatchOutput(new System.InvalidOperationException("boom")); + + Assert.True(dict.ContainsKey("error_message")); + Assert.Equal("boom", (string)dict["error_message"]); + Assert.False(dict.ContainsKey(StructuredErrorSerializer.OutputKey)); + Assert.Single(dict); + } + + [Fact] + public void RoundTrip_helper_output_is_parsed_back() + { + var error = new StructuredError + { + Code = "UNCLASSIFIED", + Reason = "generic failure", + ReferenceError = "https://rom/resourceOrder/7" + }; + + var outputData = StructuredErrorSerializer.ToOutputData(error); + + Assert.True(StructuredErrorSerializer.TryParse(outputData, out var parsed)); + Assert.Equal(error.Code, parsed.Code); + Assert.Equal(error.Reason, parsed.Reason); + Assert.Equal(error.ReferenceError, parsed.ReferenceError); + Assert.Equal(error.Version, parsed.Version); + } + + [Fact] + 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.Equal("RESOURCE_UNAVAILABLE", parsed.Code); + Assert.Equal("No port available", parsed.Reason); + } + + [Fact] + public void TryParse_returns_false_when_key_absent() + { + var dict = new Dictionary { ["error_message"] = "boom" }; + + Assert.False(StructuredErrorSerializer.TryParse(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.Null(parsed); + } + + [Fact] + public void TryParse_returns_false_on_malformed_payload() + { + var dict = new Dictionary { [StructuredErrorSerializer.OutputKey] = "not-a-structured-error" }; + + Assert.False(StructuredErrorSerializer.TryParse(dict, out _)); + } + + [Fact] + public void TryParse_returns_false_when_code_missing() + { + var dict = new Dictionary + { + [StructuredErrorSerializer.OutputKey] = new Dictionary { ["reason"] = "no code here" } + }; + + Assert.False(StructuredErrorSerializer.TryParse(dict, out _)); + } + } +} From 930e842b1247d3bf95c2978588ff29f1b6583d93 Mon Sep 17 00:00:00 2001 From: andrejsimic Date: Tue, 21 Jul 2026 10:04:55 +0200 Subject: [PATCH 2/3] Bump ConductorSharp packages to 4.1.0 Minor bump for the additive structured_error capability ported from v3.8.0. Co-Authored-By: Claude Opus 4.8 --- src/ConductorSharp.Client/ConductorSharp.Client.csproj | 2 +- src/ConductorSharp.Engine/ConductorSharp.Engine.csproj | 2 +- .../ConductorSharp.KafkaCancellationNotifier.csproj | 2 +- src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj | 2 +- src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ConductorSharp.Client/ConductorSharp.Client.csproj b/src/ConductorSharp.Client/ConductorSharp.Client.csproj index 006c6dee..0bda519d 100644 --- a/src/ConductorSharp.Client/ConductorSharp.Client.csproj +++ b/src/ConductorSharp.Client/ConductorSharp.Client.csproj @@ -6,7 +6,7 @@ Codaxy Codaxy ConductorSharp.Client - 4.0.1 + 4.1.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 4463a61a..cae17bed 100644 --- a/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj +++ b/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj @@ -6,7 +6,7 @@ Codaxy Codaxy ConductorSharp.Engine - 4.0.1 + 4.1.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.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj b/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj index 7d05745d..ac791c84 100644 --- a/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj +++ b/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj @@ -4,7 +4,7 @@ net6.0 enable enable - 4.0.1 + 4.1.0 Codaxy Codaxy diff --git a/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj b/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj index c4e9f4bc..bb0e8625 100644 --- a/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj +++ b/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj @@ -7,7 +7,7 @@ False Codaxy Codaxy - 4.0.1 + 4.1.0 diff --git a/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj b/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj index 40c7acfc..af3976e8 100644 --- a/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj +++ b/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj @@ -7,7 +7,7 @@ disable true dotnet-conductorsharp - 4.0.1 + 4.1.0 From 847195999e11e87f37ca3cc1bee96980aa2ca459 Mon Sep 17 00:00:00 2001 From: andrejsimic Date: Tue, 21 Jul 2026 10:12:49 +0200 Subject: [PATCH 3/3] Trigger release workflow on v4.* tags as well as v3.* The v4 line is now published from master; add v4.* so a v4 tag fires the same pack/push steps. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1634b198..cea66121 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Deploy packages on: push: - tags: ["v3.*"] + tags: ["v3.*", "v4.*"] workflow_dispatch: jobs: