Handle errors in trains
Source Junction.cs, ServiceTrain.cs
A junction fails by throwing. The chain catches the exception, skips the junctions after it and hands the exception back to the caller with its type and message unchanged. Why it works that way is in The Railway; this page is what to write.
That is what code in the same process sees. A GraphQL caller sees less, so nothing internal leaks:
only a plain TrainException's message reaches it, as TRAX_TRAIN_ERROR. A subclass of
TrainException becomes "The train failed.", and any other exception, an
InvalidOperationException included, becomes HotChocolate's masked "Unexpected Execution Error".
Throw a plain TrainException for a message the caller should read. See
Error codes.
Fail a junction
Throw the exception you want the caller to see. To turn a dependency's exception into one of your own, catch it in the junction that calls the dependency:
using Trax.Core.Junction;
public class ChargeCardJunction(IPaymentGateway gateway) : Junction<PaymentRequest, PaymentResult>
{
public override async Task<PaymentResult> Run(PaymentRequest input)
{
try
{
return await gateway.ChargeAsync(input, CancellationToken);
}
catch (TimeoutException ex)
{
throw new PaymentException("Payment gateway timed out", ex);
}
}
}Do not catch an exception only to return a default value: the next junction then runs on input that is not real.
Once a junction throws, no later junction runs, and Run throws the same exception the junction
did. This checkout, from Trax.Core's tests, has a card that is always declined, so the receipt is
never sent:
From Trax.Core/tests/Trax.Core.Tests.Integration/Examples/Failures/Checkout.cs
public class ChargeCard : Junction<Order, Payment>
{
public override Task<Payment> Run(Order order) =>
throw new InvalidOperationException("Card declined");
}
public class SendReceipt(IOutbox outbox) : Junction<Payment, Receipt>
{
public override Task<Receipt> Run(Payment payment)
{
outbox.Sent.Add($"receipt for {payment.Amount}");
return Task.FromResult(new Receipt(payment.Amount));
}
}
public class CheckoutTrain(IOutbox outbox) : Train<Order, Receipt>
{
protected override Task<Either<Exception, Receipt>> Junctions() =>
AddServices(outbox).Chain<ChargeCard>().Chain<SendReceipt>().Resolve();
}From Trax.Core/tests/Trax.Core.Tests.Integration/Examples/Failures/FailedJunctionTests.cs
[Test]
public async Task Run_ChargeCardThrows_SendsNoReceiptAndThrowsTheSameException()
{
var outbox = new Outbox();
var checkout = () => new CheckoutTrain(outbox).Run(new Order(20m));
var declined = await checkout.Should().ThrowAsync<InvalidOperationException>();
declined.Which.Message.Should().Be("Card declined");
outbox.Sent.Should().BeEmpty();
}React to a failure in the train
On a ServiceTrain, override OnFailed. It runs once, after the run's failure is recorded, with
the exception and the run's metadata:
using Trax.Core.Functional;
using Trax.Effect.Models.Metadata;
using Trax.Effect.Services.ServiceTrain;
public interface IProcessOrderTrain : IServiceTrain<ProcessOrderRequest, ProcessOrderResult>;
public class ProcessOrderTrain
: ServiceTrain<ProcessOrderRequest, ProcessOrderResult>,
IProcessOrderTrain
{
protected override Task<Either<Exception, ProcessOrderResult>> Junctions() =>
Chain<ValidateOrderJunction>()
.Chain<ChargeCardJunction>()
.Chain<FulfillOrderJunction>()
.Resolve();
protected override Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct)
{
if (exception is PaymentException payment)
Logger?.LogWarning(
"Payment failed for order {OrderId}: {Error}",
metadata.GetInput<ProcessOrderRequest>()?.OrderId,
payment.Message
);
return Task.CompletedTask;
}
}A cancellation that someone asked for calls OnCancelled instead, and the run is recorded as
Cancelled. An OperationCanceledException nobody asked for, such as an HttpClient timeout, is a
failure and calls OnFailed. The hooks are listed on
ServiceTrain; for a hook that runs for
every train, see AddLifecycleHook.
Handle the failure in the caller
Run, and RunAsync on the train bus, throw the junction's exception. Catch the types you can do
something about, and keep a cancellation apart from a failure:
using Trax.Mediator.Services.TrainBus;
public class CheckoutService(ITrainBus trainBus)
{
public async Task<string> Checkout(ProcessOrderRequest request, CancellationToken ct)
{
try
{
var result = await trainBus.RunAsync<ProcessOrderResult>(request, ct);
return $"Order {result.OrderId} placed";
}
catch (PaymentException ex)
{
return $"Payment declined: {ex.Message}";
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return "Cancelled";
}
}
}On a plain Train, RunEither returns the exception as Left instead of throwing it, a
cancellation included, so check for OperationCanceledException before treating a Left as a
business failure:
using Trax.Core.Functional;
using Trax.Core.Train;
public class QuoteTrain : Train<QuoteRequest, decimal>
{
protected override Task<Either<Exception, decimal>> Junctions() =>
Chain<PriceQuoteJunction>().Resolve();
}
public static class Quotes
{
public static async Task<string> Describe(QuoteTrain train, QuoteRequest request)
{
var result = await train.RunEither(request);
return result.Match(
Left: exception =>
exception is OperationCanceledException ? "Cancelled" : $"Failed: {exception.Message}",
Right: price => $"Quoted {price:C}"
);
}
}Record what kind of failure it was
To record whether a failure is transient, a conflict or permanent, so the dashboard and your own code can act on it without matching exception messages, register an IFailureClassifier. Failure classification explains what it is asked and when.
SDK Reference
Junctions | Chain | Resolve | Run / RunEither | RunAsync | ServiceTrain