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
4 changes: 4 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copilot Instructions

## Project Guidelines
- When reporting test health for this repository, prefer validating with `dotnet test --configuration Release` because the user runs tests in Release and gets passing results.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ public async ValueTask<bool> TryHandleAsync(
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(exception);

cancellationToken.ThrowIfCancellationRequested();

if (httpContext.Response.HasStarted)
{
return false;
Expand Down Expand Up @@ -51,6 +53,8 @@ public async ValueTask<bool> TryHandleAsync(

httpContext.Response.StatusCode = problem.StatusCode;

cancellationToken.ThrowIfCancellationRequested();

return await problemDetailsService.TryWriteAsync(
new ProblemDetailsContext
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Diagnostics;

namespace CrossSystemCapabilityExchange;

public sealed class CrossSystemGateway(
Expand All @@ -6,6 +8,9 @@ public sealed class CrossSystemGateway(
IExportExecutor executor,
string executionDestination)
{
private static readonly ActivitySource _activitySource =
new("CrossSystemCapabilityExchange.Gateway");

private long _decisionSequence;

public async Task<GatewayResult> ExecuteAsync(
Expand Down Expand Up @@ -82,10 +87,20 @@ await executor.ExportAsync(
{
throw;
}
catch (Exception)
catch (Exception exception)
{
string failureCategory = ClassifyExecutionFailure(exception);

RecordExecutionFailure(
exception,
failureCategory,
recipientDecisionId,
executionId,
artifact.Capability.CapabilityId,
context.CorrelationId);

// Do not propagate executor exception details across the recipient
// boundary. Internal telemetry may capture the exception separately;
// boundary. Internal telemetry captures categorized failure details;
// the gateway returns only the stable failure category here.
return GatewayResult.ExecutionFailed(
recipientDecisionId,
Expand All @@ -97,4 +112,47 @@ await executor.ExportAsync(
recipientDecisionId,
executionId);
}

private static string ClassifyExecutionFailure(Exception exception)
{
return exception switch
{
TimeoutException => "executor.timeout",
UnauthorizedAccessException => "executor.authorization",
ArgumentException => "executor.contract",
InvalidOperationException => "executor.state",
IOException => "executor.io",
_ => "executor.unexpected"
};
}

private static void RecordExecutionFailure(
Exception exception,
string failureCategory,
string recipientDecisionId,
string executionId,
string capabilityId,
string correlationId)
{
using Activity? activity =
_activitySource.StartActivity(
"cross-system.execution.failure",
ActivityKind.Internal);

activity?.SetTag("error.type", exception.GetType().FullName);
activity?.SetTag("error.category", failureCategory);
activity?.SetTag("gateway.recipient_decision_id", recipientDecisionId);
activity?.SetTag("gateway.execution_id", executionId);
activity?.SetTag("gateway.capability_id", capabilityId);
activity?.SetTag("gateway.correlation_id", correlationId);

Trace.TraceError(
"CrossSystemGateway execution failed. Category={0}; ExceptionType={1}; RecipientDecisionId={2}; ExecutionId={3}; CapabilityId={4}; CorrelationId={5}",
failureCategory,
exception.GetType().Name,
recipientDecisionId,
executionId,
capabilityId,
correlationId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -395,31 +395,42 @@ public async Task TwoActuallyConcurrentClaimsProduceOneExecution()
RecipientExportContext context =
SampleScenarios.CreateContext();

using var ready = new CountdownEvent(2);
using var start = new ManualResetEventSlim(false);
CancellationToken cancellationToken =
TestContext.Current.CancellationToken;
var allReady =
new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var start =
new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
int readyCount = 0;

Task<GatewayResult>[] tasks =
[.. Enumerable.Range(0, 2)
.Select(_ => Task.Run(async () =>
{
ready.Signal();
start.Wait();

return await gateway.ExecuteAsync(
artifact,
context,
CancellationToken.None);
}))];

Assert.True(
ready.Wait(
TimeSpan.FromSeconds(5),
TestContext.Current.CancellationToken));
start.Set();
[RunAttemptAsync(), RunAttemptAsync()];

await allReady.Task.WaitAsync(
TimeSpan.FromSeconds(5),
cancellationToken);
start.TrySetResult(true);

GatewayResult[] results =
await Task.WhenAll(tasks);

async Task<GatewayResult> RunAttemptAsync()
{
if (Interlocked.Increment(ref readyCount) == 2)
{
allReady.TrySetResult(true);
}

await start.Task.WaitAsync(cancellationToken);

return await gateway.ExecuteAsync(
artifact,
context,
cancellationToken);
}

Assert.Single(results, result => result.Executed);
Assert.Single(
results,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -475,30 +475,39 @@ public async Task TwoActuallyConcurrentContinuationClaimsProduceOneExecution()
CancellationToken cancellationToken =
TestContext.Current.CancellationToken;

using var ready = new CountdownEvent(2);
using var start = new ManualResetEventSlim(false);
var allReady =
new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var start =
new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
int readyCount = 0;

Task<GatewayResult>[] tasks =
[.. Enumerable.Range(0, 2)
.Select(_ => Task.Run(async () =>
{
ready.Signal();
start.Wait(cancellationToken);

return await gateway.ExecuteAsync(
request,
evidence,
cancellationToken);
}, cancellationToken))];
[RunAttemptAsync(), RunAttemptAsync()];

Assert.True(
ready.Wait(
TimeSpan.FromSeconds(5),
cancellationToken));
start.Set();
await allReady.Task.WaitAsync(
TimeSpan.FromSeconds(5),
cancellationToken);
start.TrySetResult(true);

GatewayResult[] results = await Task.WhenAll(tasks);

async Task<GatewayResult> RunAttemptAsync()
{
if (Interlocked.Increment(ref readyCount) == 2)
{
allReady.TrySetResult(true);
}

await start.Task.WaitAsync(cancellationToken);

return await gateway.ExecuteAsync(
request,
evidence,
cancellationToken);
}

Assert.Single(results, result => result.Executed);
Assert.Single(
results,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ private static void UseExceptionBoundary(
catch (InvalidOperationException exception)
{
observe("exception-boundary:handled");
observe($"exception-boundary:exception:{exception.GetType().Name}");

context.Response.StatusCode =
StatusCodes.Status500InternalServerError;
Expand All @@ -87,7 +88,7 @@ private static void UseExceptionBoundary(
"text/plain";

await context.Response.WriteAsync(
$"Handled by demo exception boundary: {exception.Message}");
"Handled by demo exception boundary.");
}
finally
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public async Task CorrectOrder_FaultProducesControlledProblemBoundaryResponse()
Assert.Equal(StatusCodes.Status500InternalServerError, context.Response.StatusCode);
Assert.Equal("text/plain", context.Response.ContentType);
Assert.Equal(
"Handled by demo exception boundary: Demonstration failure.",
"Handled by demo exception boundary.",
body);
}

Expand Down
Loading