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:
// 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:
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:
// 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:
// 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);
}
}