When your gRPC service responds with INTERNAL (code 13), something has broken that should never break. Unlike application-level errors your code explicitly defines, INTERNAL is gRPC's way of saying "I don't know what went wrong but it's serious enough that I couldn't give you the answer you asked for." This post walks you through what grpc status 13 actually means, why it hides the real problem, and how to find what's underneath.
The gRPC spec reserves INTERNAL for "internal errors" — conditions that shouldn't happen in normal operation. A server receives a request it can understand, but something inside the request/response cycle fails. It could be a panic in your handler, a serialization failure, or the HTTP/2 layer refusing to send the response. The code tells you nothing specific; your job is to figure out which invariant was violated.
What INTERNAL (Code 13) means
The gRPC specification defines INTERNAL as reserved for errors that indicate something is wrong with the gRPC system itself, not the application logic. When a server returns INTERNAL, it's stating: "I received your request, I started processing it, but something inside the runtime broke and I can't return the answer you expect."
This is distinct from UNKNOWN (code 2), which typically means an exception leaked from your handler without being caught and converted to a gRPC status. Many servers map any uncaught panic or exception to one of these two codes, so the code alone tells you almost nothing — see the full status code reference for how the rest of the set divides up. The real error lives in server logs.
UNKNOWN usually surfaces when a handler throws an unhandled exception. INTERNAL is for the cases where the runtime itself detected something was wrong — after your handler code ran successfully or while it was running — making it a "protocol-level" or "framework-level" failure rather than an application failure.
Common causes of INTERNAL (13)
Uncaught panic or exception in a handler
If your handler throws an exception that your interceptor doesn't catch, or if Go code panics, the gRPC framework catches it before the response can be sent. It then returns INTERNAL without exposing the panic message to the client (to avoid leaking internal state).
Protobuf serialization or deserialization failure
If a message field violates a proto constraint (e.g. a repeated field contains a value of the wrong type, or a oneof has multiple fields set when encoded), the marshaling layer fails. This is rare if your proto definitions are correct, but can happen if memory corruption or a race condition corrupts a message before sending.
Message larger than max receive size
Both client and server enforce a maximum message size (default ~4 MB). If a response is larger than the server's MaxSendMsgSize or the client's MaxRecvMsgSize, the message is rejected mid-stream. The connection doesn't break, but the RPC fails with INTERNAL because the frame can't be sent.
This is one of the most common INTERNAL errors in production. If you're serializing large objects or lots of data into a single message, check your size limits. Increase MaxRecvMsgSize and MaxSendMsgSize on both client and server, and confirm your actual message sizes.
Compression mismatch or compression library failure
The client and server agree on a compression algorithm (e.g. gzip), but if the compression library is missing or returns an error during compression/decompression, the stream fails mid-flight. This is rare on standard deployments but can occur in constrained environments.
HTTP/2 protocol errors
gRPC runs over HTTP/2. If the underlying HTTP/2 connection encounters a protocol violation (e.g. invalid frame, stream ID mismatch, or connection state error), the server converts it to INTERNAL rather than returning it to the application layer.
Proxy or intermediary mangling trailers
gRPC sends status code and status message in HTTP/2 trailers (metadata at the end of the stream). If a proxy or load balancer doesn't understand gRPC and strips or corrupts trailers, the client won't receive the actual status and may see INTERNAL.
TLS or transport-layer issues
If a TLS handshake fails mid-stream, or if the underlying TCP connection breaks before the response is sent, the server reports INTERNAL because the transport layer failed, not the application.
How to find the real error
The code doesn't tell you which of these happened. You have to look at server-side logs, metrics, or stack traces. Here's how to surface the real cause.
Use interceptors to catch panics and log them
In Go, register a unary interceptor that recovers panics and logs the stack trace before mapping to INTERNAL:
func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in %s: %v\nStack: %s", info.FullMethod, r, debug.Stack())
}
}()
return handler(ctx, req)
}
server := grpc.NewServer(grpc.UnaryInterceptor(loggingInterceptor))
In Java (using grpc-java), register a ServerInterceptor:
public class PanicLoggingInterceptor implements ServerInterceptor {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
return new ServerCall.Listener<ReqT>() {
@Override
public void onMessage(ReqT message) {
try {
next.startCall(call, headers).onMessage(message);
} catch (Exception e) {
logger.error("Error processing message", e);
call.close(
Status.INTERNAL.withDescription(e.getMessage()),
new Metadata());
}
}
};
}
}
server.getServerCallHandler().addInterceptor(new PanicLoggingInterceptor());
In Node.js (using @grpc/grpc-js), wrap your handler:
async function safeHandler(call, callback) {
try {
return await myRealHandler(call, callback);
} catch (err) {
console.error("Handler error:", err.stack);
callback(err); // grpc-js converts this to INTERNAL if not a gRPC status
}
}
Enable debug logging
Most gRPC libraries support debug or verbose logging. Turn it on in development and staging:
Go:
GODEBUG=http2debug=1 ./server
Java:
-Dgrpc.verbosity=debug
Node.js:
DEBUG=grpc:* node server.js
This prints HTTP/2 frames, trailers, and stream state, letting you see if a frame is malformed or if the connection is being reset.
Check message sizes
Log the size of messages before sending:
resp := &pb.MyResponse{...}
data, _ := proto.Marshal(resp)
log.Printf("Response size: %d bytes, limit: %d", len(data), server.MaxSendMsgSize)
Use error tracking to correlate INTERNAL with stack traces
If you instrument your server with error tracking, exceptions are logged with full stack traces, user context, and breadcrumbs. When a gRPC endpoint fails with INTERNAL, you can correlate the error event (with the real exception) to the RPC call, and see exactly what broke.
Best practices for INTERNAL errors
1. Log the real error server-side, never leak it to clients. Capture the panic or exception in an interceptor, log it with full context (request ID, user, operation), and return a generic INTERNAL status to the client. Store the real error in your error tracker.
// Good
func loggingUnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
resp, err := handler(ctx, req)
if err != nil {
requestID := ctx.Value("request-id")
logger.WithField("request_id", requestID).Error("Handler failed", err)
return nil, status.Error(codes.Internal, "internal server error")
}
return resp, nil
}
2. Test large payloads. Verify your actual message sizes don't exceed max-send/max-recv size limits, especially if you're serializing lists or large objects.
3. Monitor for INTERNAL errors. Unlike UNAVAILABLE (temporary network issue) or DEADLINE_EXCEEDED (client timeout), INTERNAL should be rare. Set up error alerting to notify you if the rate spikes.
4. Use structured logging to include request metadata. When an INTERNAL error occurs, log the request ID, caller, method, and error details in a structured format so you can search and correlate later.
5. For gRPC services, also use distributed tracing. Tools that capture span waterfalls across service boundaries help you see where in the chain the HTTP/2 stream broke or when the message size limit was exceeded. Combined with how to read a stack trace, tracing connects the dots between the gRPC status code and the real failure.
Start tracking errors in minutes
Instrument your gRPC services with LightTrace to capture stack traces and full request context every time an INTERNAL error occurs, so you can fix the real problem, not just the status code.