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:
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.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/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
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 _));
+ }
+ }
+}