ASP.NET gRPC — Custom Error Handling — Digitteck
ASP.NET gRPC — Custom Error Handling
dotnet·25 April 2020·6 min read

ASP.NET gRPC — Custom Error Handling

gRPC represents a big step in microservices communication for speed and performance. The compiler generates both client and server from a single proto file — fast to develop, type-safe by design. But the built-in error model is intentionally minimal:

"The error model described above is the official gRPC error model … it's quite limited and doesn't include the ability to communicate error details."

When server-side validation fails, the client receives only a generic RpcException with no actionable details. The solution the gRPC community recommends is passing structured error data via metadata.

The Setup

Three projects: a common library, a gRPC server (the stock SayHello template), and a console client. The server uses FluentValidation and throws on invalid requests. Before the fix the client catches nothing useful:

csharp
// Server — Greeter service using FluentValidation
[GrpcService]
public class GreeterService : Greeter.GreeterBase
{
    public override async Task<HelloReply> SayHello(
        HelloRequest request, ServerCallContext context)
    {
        await new HelloRequestValidator().ValidateAndThrowAsync(request);
        return new HelloReply { Message = 
quot;Hello {request.Name}"
}; } } // Validator public class HelloRequestValidator : AbstractValidator<HelloRequest> { public HelloRequestValidator() { RuleFor(x => x.Name).NotEmpty().MinimumLength(3); } } // Client — before fix: only catches RpcException with minimal information var client = new Greeter.GreeterClient(channel); try { var reply = await client.SayHelloAsync(new HelloRequest { Name = "" }); } catch (RpcException ex) { // ex.Status.Detail is just "Exception was thrown by handler." // No validation failure messages accessible here Console.WriteLine(ex.Status.Detail); }

Solution — ExceptionHandler + Supporting Types

The strategy: a static ExceptionHandler serializes the exception into metadata on the server side and deserializes it back on the client side. A [ExceptionSerializer] attribute on the exception class declares which serializer to use — keeping the handler generic and exception types independent:

csharp
public static class ExceptionHandler
{
    // metadata keys are always lowercased when received
    public const string METADATA_KEY = "capturedexception";

    public static bool SetException(Metadata metadata, Exception exception)
    {
        Type actualType = exception.GetType();
        IExceptionSerializer exceptionSerializer = GetSerializer(actualType);

        if (exceptionSerializer != null)
        {
            MemoryStream memoryStream = new MemoryStream();
            exceptionSerializer.Serialize(exception, memoryStream);
            memoryStream.Seek(0, SeekOrigin.Begin);
            byte[] byteContent = memoryStream.ToArray();

            CapturedExceptionMeta exceptionContent = new CapturedExceptionMeta(actualType, byteContent);
            string serializedExceptionContent = JsonConvert.SerializeObject(exceptionContent);
            metadata.Add(new Metadata.Entry(METADATA_KEY, serializedExceptionContent));
            return true;
        }
        return false;
    }

    public static Exception TryGetException(Exception exception)
    {
        if (exception is RpcException rpcException)
        {
            if (rpcException.Trailers != null
             && rpcException.StatusCode == StatusCode.Unknown
             && string.Equals(rpcException.Status.Detail, ExceptionHandler.METADATA_KEY,
                    StringComparison.OrdinalIgnoreCase))
            {
                return ExceptionHandler.TryGetException(rpcException.Trailers);
            }
        }
        else if (exception.InnerException != null)
        {
            return TryGetException(exception.InnerException);
        }
        return null;
    }

    private static IExceptionSerializer GetSerializer(Type actualType)
    {
        if (actualType.GetCustomAttribute<ExceptionSerializerAttribute>()
            is ExceptionSerializerAttribute serializer)
        {
            return (IExceptionSerializer)Activator.CreateInstance(serializer.SerializerType);
        }
        return null;
    }

    private static Exception TryGetException(Metadata metadata)
    {
        string serializedExceptionContent = metadata
            .SingleOrDefault(x => string.Equals(x.Key, METADATA_KEY, StringComparison.OrdinalIgnoreCase))
            ?.Value;

        if (serializedExceptionContent == null) return null;

        CapturedExceptionMeta exceptionContent =
            JsonConvert.DeserializeObject<CapturedExceptionMeta>(serializedExceptionContent);

        MemoryStream memoryStream = new MemoryStream(exceptionContent.Serialized);
        memoryStream.Seek(0, SeekOrigin.Begin);

        IExceptionSerializer exceptionSerializer = GetSerializer(exceptionContent.ExceptionType);
        if (exceptionSerializer == null)
            throw new Exception(
quot;Serializer for {exceptionContent.ExceptionType} not found."
); return exceptionSerializer.Deserialize(memoryStream); } } // Supporting types [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] public class ExceptionSerializerAttribute : Attribute { public Type SerializerType { get; } public ExceptionSerializerAttribute(Type serializerType) => SerializerType = serializerType; } public interface IExceptionSerializer { void Serialize(Exception exception, Stream stream); Exception Deserialize(Stream stream); } public class CapturedExceptionMeta { [JsonProperty("ExceptionType")] public Type ExceptionType { get; } [JsonProperty("Serialized")] public byte[] Serialized { get; } [JsonConstructor] public CapturedExceptionMeta(Type exceptionType, byte[] serialized) { ExceptionType = exceptionType; Serialized = serialized; } }

Interceptor

A gRPC server interceptor catches every unhandled exception, invokes ExceptionHandler.SetException, and rethrows as an RpcException with the serialized content in its trailers:

csharp
// Server interceptor — catches exceptions and packs them into gRPC metadata
public class ServerExceptionInterceptor : Interceptor
{
    public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest request,
        ServerCallContext context,
        UnaryServerMethod<TRequest, TResponse> continuation)
    {
        try
        {
            return await continuation(request, context);
        }
        catch (Exception ex)
        {
            var metadata = new Metadata();
            if (ExceptionHandler.SetException(metadata, ex))
            {
                throw new RpcException(
                    new Status(StatusCode.Unknown, ExceptionHandler.METADATA_KEY),
                    metadata);
            }
            throw;
        }
    }
}

// Register with gRPC in Startup
services.AddGrpc(options =>
{
    options.Interceptors.Add<ServerExceptionInterceptor>();
});

End-to-End Usage

Decorate the exception class with [ExceptionSerializer] and provide a minimal serializer. The client calls TryGetException to reconstruct the typed exception from the trailers:

csharp
// 1. Create a minimal serializer for the exception (only what the client needs)
[ExceptionSerializer(typeof(ValidationExceptionSerializer))]
public class ValidationException : Exception
{
    public IEnumerable<ValidationFailure> Errors { get; }
    public ValidationException(IEnumerable<ValidationFailure> errors) => Errors = errors;
}

public class ValidationExceptionSerializer : IExceptionSerializer
{
    public void Serialize(Exception exception, Stream stream)
    {
        var ex = (ValidationException)exception;
        var messages = ex.Errors.Select(e => e.ErrorMessage).ToArray();
        using var writer = new StreamWriter(stream, leaveOpen: true);
        writer.Write(JsonConvert.SerializeObject(messages));
    }

    public Exception Deserialize(Stream stream)
    {
        using var reader = new StreamReader(stream, leaveOpen: true);
        var messages = JsonConvert.DeserializeObject<string[]>(reader.ReadToEnd());
        return new ValidationException(messages.Select(m => new ValidationFailure("", m)));
    }
}

// 2. Client — recreate the original exception from metadata
try
{
    var reply = await client.SayHelloAsync(new HelloRequest { Name = "" });
}
catch (Exception ex)
{
    var captured = ExceptionHandler.TryGetException(ex);
    if (captured is ValidationException validationEx)
    {
        foreach (var error in validationEx.Errors)
            Console.WriteLine(error.ErrorMessage);
    }
}

Tags

.NETASP.NET CoregRPC
digitteck

© 2026 Digitteck