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 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>3.8.0</Version>
<Version>3.8.1</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>3.8.0</Version>
<Version>3.8.1</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
39 changes: 37 additions & 2 deletions src/ConductorSharp.Engine/Exceptions/StructuredErrorException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,22 @@ 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
/// and the diagnostic message 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>
/// <remarks>
/// There is deliberately no <c>Message</c> property here: the diagnostic message is carried by the inherited
/// <see cref="Exception.Message"/>, which the message-taking constructors set. When no message is supplied it
/// falls back to <see cref="Reason"/>, matching the behaviour of the original constructors.
/// </remarks>
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>
/// <summary>Short, stable, 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>
Expand All @@ -36,5 +41,35 @@ public StructuredErrorException(string code, string reason, string referenceErro
Reason = reason;
ReferenceError = referenceError;
}

/// <summary>
/// Declares a diagnostic <paramref name="message"/> distinct from the short, stable
/// <paramref name="reason"/>. Pass <c>null</c> for <paramref name="message"/> to fall back to the reason,
/// and <c>null</c> for <paramref name="referenceError"/> when there is no entity to drill down into.
/// </summary>
/// <remarks>
/// <paramref name="message"/> trails <paramref name="referenceError"/> rather than following
/// <paramref name="reason"/> on purpose. Overload resolution cannot choose between
/// this constructor and the <see cref="Exception"/> one when the fourth argument is an untyped <c>null</c>,
/// so the nullable parameter is placed third, where it is typed the same either way. The only call this
/// leaves ambiguous is <c>(code, reason, referenceError, null)</c> — a declared-but-null inner exception,
/// which the three-argument constructor already expresses. Disambiguate with a named argument if needed.
/// </remarks>
public StructuredErrorException(string code, string reason, string referenceError, string message)
: base(message ?? reason)
{
Code = code;
Reason = reason;
ReferenceError = referenceError;
}

/// <inheritdoc cref="StructuredErrorException(string, string, string, string)"/>
public StructuredErrorException(string code, string reason, string referenceError, string message, Exception innerException)
: base(message ?? reason, innerException)
{
Code = code;
Reason = reason;
ReferenceError = referenceError;
}
}
}
12 changes: 1 addition & 11 deletions src/ConductorSharp.Engine/ExecutionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -247,19 +247,9 @@ await _taskManager.UpdateAsync(
pollResponse.WorkflowInstanceId
);

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
};
}
var errorMessage = new ErrorOutput { ErrorMessage = exception.Message, StructuredError = StructuredError.FromException(exception) };

// 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...)
Expand Down
35 changes: 34 additions & 1 deletion src/ConductorSharp.Engine/Model/StructuredError.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using System;
using ConductorSharp.Engine.Exceptions;

namespace ConductorSharp.Engine.Model
{
/// <summary>
Expand All @@ -12,13 +15,43 @@ public class StructuredError
/// <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>
/// <summary>Short, stable, sanitized reason. Consumers may key off this text, so keep it terse.</summary>
public string Reason { get; set; }

/// <summary>
/// Optional diagnostic detail, longer and more specific than <see cref="Reason"/> — the explanation an
/// operator needs, kept out of <see cref="Reason"/> so that stays short and stable. Null when the producer
/// supplied nothing distinct from the reason, in which case it is omitted from serialized output
/// (NullValueHandling.Ignore) and the payload is unchanged from before this field existed.
/// </summary>
public string Message { 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;

/// <summary>
/// Maps a thrown exception onto the payload, returning <c>null</c> for anything that is not a
/// <see cref="StructuredErrorException"/> so plain exceptions keep producing only <c>error_message</c>.
/// This is the single exception-to-payload mapping: both execution managers and the contract tests call it,
/// so the two poll strategies cannot drift apart as the shape evolves.
/// </summary>
public static StructuredError FromException(Exception exception)
{
if (exception is not StructuredErrorException structuredException)
return null;

return new StructuredError
{
Code = structuredException.Code,
Reason = structuredException.Reason,
// Exception.Message falls back to Reason when the thrower supplied no distinct detail, so only
// carry it when it actually adds something. Existing call sites keep their exact payload.
Message = structuredException.Message == structuredException.Reason ? null : structuredException.Message,
ReferenceError = structuredException.ReferenceError
};
}
}
}
12 changes: 1 addition & 11 deletions src/ConductorSharp.Engine/TypePollSpreadingExecutionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,19 +256,9 @@ await _taskManager.UpdateAsync(
pollResponse.WorkflowInstanceId
);

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
};
}
var errorMessage = new ErrorOutput { ErrorMessage = exception.Message, StructuredError = StructuredError.FromException(exception) };

// 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...)
Expand Down
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>3.8.0</Version>
<Version>3.8.1</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>3.8.0</Version>
<Version>3.8.1</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
117 changes: 99 additions & 18 deletions test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using ConductorSharp.Client;
using ConductorSharp.Client.Util;
using ConductorSharp.Engine.Exceptions;
Expand All @@ -12,40 +13,33 @@ 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.
// Mirrors the execution-manager catch block. It calls StructuredError.FromException — the same mapping both
// ExecutionManager and TypePollSpreadingExecutionManager use — rather than reimplementing it, so a field
// added to the payload cannot pass here while being dropped by one of the managers.
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
};
}
var output = new ErrorOutput { ErrorMessage = exception.Message, StructuredError = StructuredError.FromException(exception) };

return SerializationHelper.ObjectToDictionary(output, ConductorConstants.IoJsonSerializerSettings);
}

private static JToken StructuredErrorOf(IDictionary<string, object> dict) =>
JObject.Parse(JsonConvert.SerializeObject(dict))[StructuredErrorSerializer.OutputKey];

[Fact]
public void StructuredErrorException_produces_snake_case_structured_error()
{
var exception = new StructuredErrorException("RESOURCE_UNAVAILABLE", "No port available", "https://rom/resourceOrder/42");
var exception = new StructuredErrorException("RESOURCE_UNAVAILABLE", "No port available", "https://example.org/entity/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"];
var structured = StructuredErrorOf(dict);
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("https://example.org/entity/42", (string)structured["reference_error"]);
Assert.Equal(StructuredError.CurrentVersion, (int)structured["version"]);
}

Expand All @@ -60,21 +54,96 @@ public void PlainException_output_is_backward_compatible()
Assert.Single(dict);
}

[Fact]
public void Message_is_carried_under_snake_case_message_key()
{
var exception = new StructuredErrorException(
"VALIDATION_FAILED",
"Input field not recognized",
"https://example.org/entity/7",
"Field 'widget_id' is not present in schema 'default'."
);

var structured = StructuredErrorOf(SerializeCatchOutput(exception));

Assert.Equal("VALIDATION_FAILED", (string)structured["code"]);
Assert.Equal("Input field not recognized", (string)structured["reason"]);
Assert.Equal("Field 'widget_id' is not present in schema 'default'.", (string)structured["message"]);
Assert.Equal("https://example.org/entity/7", (string)structured["reference_error"]);
}

[Fact]
public void Message_reaches_error_message_and_reason_for_incompletion()
{
// error_message is set from Exception.Message, which the message-taking constructor overrides. The same
// value is what the execution manager sends as TaskResult.ReasonForIncompletion (the Conductor UI banner).
var exception = new StructuredErrorException("CODE", "Short reason", null, "Long diagnostic detail");

Assert.Equal("Long diagnostic detail", exception.Message);
Assert.Equal("Long diagnostic detail", (string)SerializeCatchOutput(exception)["error_message"]);
}

[Fact]
public void Message_is_omitted_when_no_message_was_supplied()
{
// Guards the backward-compatibility promise: pre-existing call sites must keep their exact payload.
var structured = StructuredErrorOf(
SerializeCatchOutput(new StructuredErrorException("CODE", "Short reason", "https://example.org/entity/1"))
);

Assert.Null(structured["message"]);
Assert.Equal(
new[] { "code", "reason", "reference_error", "version" },
((JObject)structured).Properties().Select(p => p.Name).OrderBy(n => n)
);
}

[Fact]
public void Message_is_omitted_when_it_only_repeats_the_reason()
{
var exception = new StructuredErrorException("CODE", "Same text", null, "Same text");

Assert.Null(StructuredErrorOf(SerializeCatchOutput(exception))["message"]);
}

[Fact]
public void Message_survives_the_round_trip()
{
var dict = SerializeCatchOutput(
new StructuredErrorException("CODE", "Short reason", "https://example.org/entity/9", "Long diagnostic detail")
);

Assert.True(StructuredErrorSerializer.TryParse(dict, out var parsed));
Assert.Equal("CODE", parsed.Code);
Assert.Equal("Short reason", parsed.Reason);
Assert.Equal("Long diagnostic detail", parsed.Message);
Assert.Equal("https://example.org/entity/9", parsed.ReferenceError);
}

[Fact]
public void FromException_returns_null_for_a_plain_exception()
{
Assert.Null(StructuredError.FromException(new System.InvalidOperationException("boom")));
}

[Fact]
public void RoundTrip_helper_output_is_parsed_back()
{
// The signal-sender producer: no exception to catch, so the payload is rendered from the model directly.
var error = new StructuredError
{
Code = "UNCLASSIFIED",
Reason = "generic failure",
ReferenceError = "https://rom/resourceOrder/7"
Message = "downstream call failed: connection refused",
ReferenceError = "https://example.org/entity/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.Message, parsed.Message);
Assert.Equal(error.ReferenceError, parsed.ReferenceError);
Assert.Equal(error.Version, parsed.Version);
}
Expand Down Expand Up @@ -123,5 +192,17 @@ public void TryParse_returns_false_when_code_missing()

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

[Fact]
public void TryParse_tolerates_a_message_only_payload_by_degrading()
{
// A message without a code is still unstructured: the caller must fall back to the generic path.
var dict = new Dictionary<string, object>
{
[StructuredErrorSerializer.OutputKey] = new Dictionary<string, object> { ["message"] = "detail but no code" }
};

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