Back to Discussions

Intermittent gRPC `UNAVAILABLE` Errors After Inactivity: Client Connection Dropped by Load Balancer/Firewall?

10 Views
Sep 28, 2026, 3:05:20 AM

Intermittent gRPC UNAVAILABLE Errors After Inactivity: Client Connection Dropped by Load Balancer/Firewall?

Context: We're developing a microservice-based e-commerce platform where services communicate primarily via gRPC. One critical interaction involves our OrderProcessingService (client) calling the InventoryService (server) to reserve stock before confirming an order. Both services are written in Go and deployed as Docker containers on Kubernetes within AWS, behind an internal AWS Network Load Balancer (NLB) for the InventoryService.

Problem Description: The issue we're facing is intermittent UNAVAILABLE errors when the OrderProcessingService tries to communicate with InventoryService after a period of inactivity. Specifically, if there are no calls to InventoryService for about 5-10 minutes, the first subsequent RPC request from OrderProcessingService fails with an UNAVAILABLE status. All subsequent requests immediately after this initial failure succeed without any problems. This behavior is highly disruptive, as it leads to failed order attempts and a poor user experience.

We've confirmed that the InventoryService pods are healthy and running continuously. There are no restarts or crashes on the server side during these incidents. The issue consistently appears after an idle period. It seems as if the underlying TCP connection is being prematurely closed by an intermediary (like the NLB or a firewall) due to inactivity, and the gRPC client isn't gracefully handling the reconnection on the first attempt.

Environment:

  • Client Service: OrderProcessingService (Go 1.20, google.golang.org/grpc v1.53.0)
  • Server Service: InventoryService (Go 1.20, google.golang.org/grpc v1.53.0)
  • Deployment: Kubernetes on AWS, InventoryService exposed via an AWS NLB.
  • OS: Linux (Alpine-based Docker images)

Code Snippets:

OrderProcessingService (Client-side connection setup):

package main

import (
	"log"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	pb "your/proto/package" // Assume pb is defined for generated gRPC client
)

var inventoryServiceClient pb.InventoryServiceClient

func init() {
	// Address of the InventoryService in Kubernetes
	inventoryServiceAddr := "inventory-service.ecommerce-system.svc.cluster.local:50051"

	conn, err := grpc.Dial(
		inventoryServiceAddr,
		grpc.WithTransportCredentials(insecure.NewCredentials()), // For simplicity in internal cluster comms
	)
	if err != nil {
		log.Fatalf("did not connect to inventory service: %v", err)
	}
	inventoryServiceClient = pb.NewInventoryServiceClient(conn)
	log.Printf("Successfully connected to inventory service at %s", inventoryServiceAddr)
}

func main() {
	// ... application logic ...
	// Example RPC call later in the code (simplified):
	// _, err := inventoryServiceClient.ReserveStock(context.Background(), &pb.ReserveStockRequest{ProductId: "prod1", Quantity: 1})
	// if err != nil {
	//     log.Printf("Error reserving stock: %v", err) // This is where UNAVAILABLE errors appear
	// }
}

InventoryService (Server-side setup):

package main

import (
	"context"
	"log"
	"net"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	pb "your/proto/package" // Assume pb is defined for generated gRPC server
)

// server implements your/proto/package.InventoryServiceServer
type server struct {
	pb.UnimplementedInventoryServiceServer
}

func (s *server) ReserveStock(ctx context.Context, req *pb.ReserveStockRequest) (*pb.ReserveStockResponse, error) {
	log.Printf("Received ReserveStock request for product %s, quantity %d", req.ProductId, req.Quantity)
	// Simulate stock reservation logic
	if req.ProductId == "non-existent" {
		return nil, status.Errorf(codes.NotFound, "Product %s not found", req.ProductId)
	}
	return &pb.ReserveStockResponse{Success: true, Message: "Stock reserved"}, nil
}

func main() {
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	s := grpc.NewServer()
	pb.RegisterInventoryServiceServer(s, &server{})
	log.Printf("server listening at %v", lis.Addr())
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

What I Expected: I expected gRPC to maintain a persistent, healthy connection or, if the connection was dropped by an intermediary, to transparently and immediately re-establish it on the next RPC call without propagating an UNAVAILABLE error to the application logic. The design goal is that RPC calls should appear reliable from the client's perspective, even after periods of inactivity.

What Actually Happened: After about 5-10 minutes of no RPC traffic between OrderProcessingService and InventoryService, the very first ReserveStock RPC call fails with an error similar to this:

rpc error: code = Unavailable desc = connection error: desc = "transport: Error while dialing: read: connection reset by peer"

Subsequent calls made immediately after this failure succeed without any issues, which indicates the server is healthy and a new connection is established, but not without an initial error.

Any insights into why this is happening and how to prevent these UNAVAILABLE errors would be greatly appreciated. Thanks!

Advertisement

Community Insights

1
A
ADITYA KUMAR
9/28/26, 3:05 AM

Explanation of the Problem (Root Cause):

The intermittent UNAVAILABLE errors you're experiencing are a classic symptom of network intermediaries (like load balancers, firewalls, or even NAT devices) silently terminating idle TCP connections. Here's why:

  1. Idle Connection Timeouts: Network infrastructure components are designed to manage resources efficiently. They often implement "idle connection timeouts" to clean up connections that haven't seen any traffic for a certain period. For example, AWS Network Load Balancers (NLBs) have an idle timeout of 350 seconds (approx. 5 minutes 50 seconds), and many corporate firewalls have similar, or even shorter, timeouts. Your observed 5-10 minute window aligns perfectly with typical load balancer or firewall idle timeouts.

  2. Half-Open Connections: When an intermediary closes an idle connection, neither the gRPC client nor the server are immediately aware. They still believe the connection is open. This state is called a "half-open" connection.

  3. Client's First Attempt Fails: When the OrderProcessingService attempts to make an RPC call after inactivity, it tries to send data over this half-open TCP connection. The intermediary (or the server's OS, if the server was the one that detected the closed connection) responds with a TCP RST (reset) packet. This causes the client's operating system to report "connection reset by peer," which gRPC then translates into an UNAVAILABLE status code.

  4. Subsequent Attempts Succeed: After the RST is received, the gRPC client correctly identifies the connection as broken and initiates a new connection attempt. This new connection is successful, and subsequent RPCs proceed without issues until the next idle timeout period.

  5. Default gRPC Keepalives Insufficient: By default, gRPC in Go doesn't send application-level pings frequently enough to reliably circumvent these common network idle timeouts. While TCP keepalives exist at the OS level, they are typically configured with very long intervals (e.g., 2 hours), making them ineffective for 5-10 minute timeouts.

The Fix:

To prevent intermediaries from silently dropping connections, you need to configure gRPC to send application-level keepalive pings at an interval shorter than your network's idle timeout. These pings are lightweight, don't carry application data, and serve to signal that the connection is still active. Both the client and server should be configured for keepalives for robust behavior.

Corrected Code Blocks:

OrderProcessingService (Client-side connection setup with keepalives):

package main

import (
	"log"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/keepalive" // Import the keepalive package
	pb "your/proto/package"
)

var inventoryServiceClient pb.InventoryServiceClient

func init() {
	inventoryServiceAddr := "inventory-service.ecommerce-system.svc.cluster.local:50051"

	// Configure client-side keepalive parameters
	// Send a ping if the connection has been idle for 60 seconds (shorter than NLB's 350s timeout)
	// Wait up to 20 seconds for a ping response before considering the connection broken
	clientKeepaliveParams := keepalive.ClientParameters{
		Time:                60 * time.Second, // Send pings every 60 seconds of inactivity
		Timeout:             20 * time.Second, // Wait 20 seconds for ping acknowledgement
		PermitWithoutStream: true,             // Crucial: Allow pings even if no active RPCs are in flight
	}

	conn, err := grpc.Dial(
		inventoryServiceAddr,
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithKeepaliveParams(clientKeepaliveParams), // Apply client keepalive settings
		// grpc.WithBlock(), // Optional: Block until connection is established initially, good for startup
	)
	if err != nil {
		log.Fatalf("did not connect to inventory service: %v", err)
	}
	inventoryServiceClient = pb.NewInventoryServiceClient(conn)
	log.Printf("Successfully connected to inventory service at %s", inventoryServiceAddr)
}

func main() {
	// ... application logic ...
}

InventoryService (Server-side setup with keepalives):

It's crucial to also configure the server with keepalive.ServerParameters and keepalive.EnforcementPolicy. The EnforcementPolicy prevents resource exhaustion from malicious or misbehaving clients sending too many pings.

package main

import (
	"context"
	"log"
	"net"
	"time" // Import time for durations

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/keepalive" // Import the keepalive package
	"google.golang.org/grpc/status"
	pb "your/proto/package"
)

type server struct {
	pb.UnimplementedInventoryServiceServer
}

func (s *server) ReserveStock(ctx context.Context, req *pb.ReserveStockRequest) (*pb.ReserveStockResponse, error) {
	log.Printf("Received ReserveStock request for product %s, quantity %d", req.ProductId, req.Quantity)
	if req.ProductId == "non-existent" {
		return nil, status.Errorf(codes.NotFound, "Product %s not found", req.ProductId)
	}
	return &pb.ReserveStockResponse{Success: true, Message: "Stock reserved"}, nil
}

func main() {
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	// Configure server-side keepalive enforcement policy
	// MinTime: Minimum duration a client should wait before sending a ping. If client pings more
	//          frequently, the connection will be terminated to prevent resource exhaustion.
	// PermitWithoutStream: If true, allows clients to send pings without any active RPCs. This MUST be
	//                      true for the client-side `PermitWithoutStream` to be effective.
	kaep := keepalive.EnforcementPolicy{
		MinTime:             5 * time.Second, // Allow client pings no more frequent than every 5 seconds
		PermitWithoutStream: true,            // VERY IMPORTANT: Allow pings from idle clients
	}

	// Server-side keepalive parameters
	// These define how the *server* initiates keepalive pings to clients, and how it handles idle connections.
	// While client pings address your immediate issue, these are good for overall robustness.
	kasp := keepalive.ServerParameters{
		MaxConnectionIdle:     30 * time.Minute, // Close a connection if it's idle (no RPCs or pings) for 30 minutes
		MaxConnectionAge:      0,                // No max age for connections (connections can live indefinitely)
		MaxConnectionAgeGrace: 0,
		Time:                  30 * time.Second, // Server pings client if idle for 30s
		Timeout:               5 * time.Second,  // Server waits 5s for client ping response
	}

	s := grpc.NewServer(
		grpc.KeepaliveEnforcementPolicy(kaep), // Apply server keepalive enforcement
		grpc.KeepaliveParams(kasp),            // Apply server keepalive parameters
	)
	pb.RegisterInventoryServiceServer(s, &server{})
	log.Printf("server listening at %v", lis.Addr())
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

By configuring these settings, especially ClientParameters.Time (shorter than intermediary timeout) and EnforcementPolicy.PermitWithoutStream: true on both client and server, you ensure that the gRPC connection remains active enough to prevent network intermediaries from closing it due to inactivity.

Edge Cases and Related Pitfalls:

  1. Overly Aggressive Keepalives: Setting keepalive.ClientParameters.Time or keepalive.ServerParameters.Time values too low (e.g., a few seconds) can lead to excessive network traffic. If you have many idle connections, frequent pings consume unnecessary bandwidth and CPU resources on both client and server, potentially creating a "noisy" network. Choose a Time value that is comfortably shorter than your network's idle timeout (e.g., 60s for a 5-minute timeout), but not excessively short.
  2. Mismatched PermitWithoutStream: If the client sets PermitWithoutStream: true (which is necessary to send pings when idle) but the server sets its EnforcementPolicy.PermitWithoutStream: false, the server will consider the client's idle pings as a violation and terminate the connection, leading back to UNAVAILABLE errors. Always ensure EnforcementPolicy.PermitWithoutStream is true on the server if clients are expected to send idle pings to keep connections alive through load balancers.

💡 Bonus Tip:

While keepalives solve the idle connection problem, other transient network issues, server restarts, or momentary congestion can still lead to UNAVAILABLE errors. Implementing client-side retry logic with exponential backoff and jitter for UNAVAILABLE and other transient error codes (ResourceExhausted, Canceled, DeadlineExceeded) is a robust pattern. Libraries like github.com/grpc-ecosystem/go-grpc-middleware/retry can simplify this significantly. This adds resilience by automatically retrying failed RPCs a few times before escalating the error to the application, improving overall system reliability and user experience. Combine this with gRPC's connection pooling to efficiently manage underlying TCP connections.

Unlock Global Discussions

The TechLab Forum is reserved for our premium engineering community. Upgrade now to participate in high-level architectural debates.

Get Pro Access