Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Deploy packages

on:
push:
tags: ["v3.*"]
tags: ["v3.*", "v4.*"]
workflow_dispatch:

jobs:
Expand Down
2 changes: 1 addition & 1 deletion src/ConductorSharp.Client/ConductorSharp.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<Authors>Codaxy</Authors>
<Company>Codaxy</Company>
<PackageId>ConductorSharp.Client</PackageId>
<Version>4.0.1</Version>
<Version>4.1.0</Version>
<Description>Client library for Netflix Conductor, with some additional quality of life features.</Description>
<RepositoryUrl>https://github.com/codaxy/conductor-sharp</RepositoryUrl>
<PackageTags>netflix;conductor</PackageTags>
Expand Down
2 changes: 1 addition & 1 deletion src/ConductorSharp.Engine/ConductorSharp.Engine.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<Authors>Codaxy</Authors>
<Company>Codaxy</Company>
<PackageId>ConductorSharp.Engine</PackageId>
<Version>4.0.1</Version>
<Version>4.1.0</Version>
<Description>Client library for Netflix Conductor, with some additional quality of life features.</Description>
<RepositoryUrl>https://github.com/codaxy/conductor-sharp</RepositoryUrl>
<PackageTags>netflix;conductor</PackageTags>
Expand Down
40 changes: 40 additions & 0 deletions src/ConductorSharp.Engine/Exceptions/StructuredErrorException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System;

namespace ConductorSharp.Engine.Exceptions
{
/// <summary>
/// Thrown by a worker to attach a structured, sanitized error classification to the failed task's output.
/// When caught by the execution manager, the <see cref="Code"/>/<see cref="Reason"/>/<see cref="ReferenceError"/>
/// are serialized under the <c>structured_error</c> output key (see
/// <see cref="ConductorSharp.Engine.Util.StructuredErrorSerializer"/>), in addition to the plain
/// <c>error_message</c>, so downstream consumers can read a stable classification without parsing free-text
/// reasons. Plain exceptions are unaffected and keep producing only <c>error_message</c>.
/// </summary>
public class StructuredErrorException : Exception
{
/// <summary>Stable, opaque classification code. Consumers map this to a failure response.</summary>
public string Code { get; }

/// <summary>Human-readable, sanitized reason. Safe to surface across a layer boundary.</summary>
public string Reason { get; }

/// <summary>Optional URI pointing at the entity where the failure originated (drill-down link).</summary>
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;
}
}
}
13 changes: 13 additions & 0 deletions src/ConductorSharp.Engine/ExecutionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/ConductorSharp.Engine/Model/ErrorOutput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,12 @@ namespace ConductorSharp.Engine.Model
public class ErrorOutput
{
public string ErrorMessage { get; set; }

/// <summary>
/// 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 <see cref="ErrorMessage"/>.
/// </summary>
public StructuredError StructuredError { get; set; }
}
}
24 changes: 24 additions & 0 deletions src/ConductorSharp.Engine/Model/StructuredError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace ConductorSharp.Engine.Model
{
/// <summary>
/// Sanitized, structured error classification transported across the <c>structured_error</c> task-output key.
/// The field shape is versioned via <see cref="Version"/> so it can evolve without silent misparses.
/// </summary>
public class StructuredError
{
/// <summary>Current structured-error payload shape version.</summary>
public const int CurrentVersion = 1;

/// <summary>Stable, opaque classification code (e.g. an implementation-defined code, or <c>UNCLASSIFIED</c>).</summary>
public string Code { get; set; }

/// <summary>Human-readable, sanitized reason.</summary>
public string Reason { get; set; }

/// <summary>Optional URI pointing at the entity where the failure originated (drill-down link).</summary>
public string ReferenceError { get; set; }

/// <summary>Payload shape version marker. Defaults to <see cref="CurrentVersion"/>.</summary>
public int Version { get; set; } = CurrentVersion;
}
}
13 changes: 13 additions & 0 deletions src/ConductorSharp.Engine/TypePollSpreadingExecutionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Collections.Generic;
using ConductorSharp.Client;
using ConductorSharp.Engine.Model;
using Newtonsoft.Json;

namespace ConductorSharp.Engine.Util
{
/// <summary>
/// Defines the <c>structured_error</c> task-output contract (key + shape) and the read side used by all consumers
/// (<see cref="TryParse"/>). There are two producers, both emitting the same shape because they serialize the same
/// <see cref="StructuredError"/> type with the same serializer settings:
/// <list type="bullet">
/// <item>the execution-manager catch block, which serializes <see cref="ErrorOutput.StructuredError"/> when a
/// worker throws a <see cref="ConductorSharp.Engine.Exceptions.StructuredErrorException"/>; and</item>
/// <item>external signal senders (which have no exception to catch), which render via <see cref="ToOutputData"/>.</item>
/// </list>
/// </summary>
public static class StructuredErrorSerializer
{
/// <summary>Well-known task-output key carrying the structured error payload.</summary>
public const string OutputKey = "structured_error";

/// <summary>
/// Renders a <see cref="StructuredError"/> to an output-data fragment (<c>{ "structured_error": { ... } }</c>)
/// using the standard snake_case IO serializer settings. Returns an empty dictionary for a null error.
/// </summary>
public static IDictionary<string, object> ToOutputData(StructuredError error)
{
if (error == null)
return new Dictionary<string, object>();

return new Dictionary<string, object> { [OutputKey] = ToOutputValue(error) };
}

/// <summary>Renders just the value placed under <see cref="OutputKey"/>, in the canonical snake_case shape.</summary>
public static object ToOutputValue(StructuredError error)
{
var json = JsonConvert.SerializeObject(error, ConductorConstants.IoJsonSerializerSettings);
return JsonConvert.DeserializeObject<IDictionary<string, object>>(json, ConductorConstants.IoJsonSerializerSettings);
}

/// <summary>
/// Tolerantly extracts a <see cref="StructuredError"/> from a failed task's output data. Presence-checks the
/// single <see cref="OutputKey"/> and deserializes only that subtree. A missing, malformed, or code-less
/// payload returns <c>false</c> and never throws, so a parse problem degrades error quality (falling back to
/// the generic path) rather than failing the failure workflow.
/// </summary>
public static bool TryParse(IDictionary<string, object> 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<StructuredError>(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;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>4.0.1</Version>
<Version>4.1.0</Version>
<Authors>Codaxy</Authors>
<Company>Codaxy</Company>
</PropertyGroup>
Expand Down
2 changes: 1 addition & 1 deletion src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
<Authors>Codaxy</Authors>
<Company>Codaxy</Company>
<Version>4.0.1</Version>
<Version>4.1.0</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<Nullable>disable</Nullable>
<PackAsTool>true</PackAsTool>
<ToolCommandName>dotnet-conductorsharp</ToolCommandName>
<Version>4.0.1</Version>
<Version>4.1.0</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
127 changes: 127 additions & 0 deletions test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, object> 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<string, object> { ["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<string, object> { [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<string, object>
{
[StructuredErrorSerializer.OutputKey] = new Dictionary<string, object> { ["reason"] = "no code here" }
};

Assert.False(StructuredErrorSerializer.TryParse(dict, out _));
}
}
}
Loading