Protocolo gRPC-Connect: Comunicación unificada frontend-backend para microservicios Go 2026
Introducción: Por qué la comunicación frontend-backend de tus microservicios sigue siendo un desastre
Estamos en 2026. Si tus microservicios Go siguen usando REST + JSON y manteniendo manualmente dos conjuntos de definiciones de API (Protobuf del backend + TypeScript del frontend), estás sumido en el siguiente pantano: el backend cambia un campo y el frontend no se entera, la documentación de Swagger siempre está desactualizada, la comunicación por streaming requiere hacks con WebSocket, los códigos de error se definen por separado en cada lado...
El protocolo gRPC-Connect pone fin a todo esto. Define un conjunto de servicios basado en Protobuf, el backend usa gRPC para comunicación de alto rendimiento y el frontend realiza llamadas sin interrupciones usando el modo HTTP/JSON de Connect-RPC. Un Proto, ambos lados funcionan. La madurez del ecosistema Buf hace esto más simple que nunca.
Este artículo te guía en la construcción de 5 patrones principales de gRPC-Connect en Go, cubriendo toda la cadena desde la definición de servicios hasta el gateway listo para producción.
Conceptos clave de un vistazo
| Concepto | Descripción | Herramientas del ecosistema |
|---|---|---|
| gRPC-Connect | Protocolo RPC basado en HTTP/2 que soporta tanto gRPC como Connect | connect-go |
| Connect-RPC | Implementación en Go del protocolo Connect, soporta modos gRPC/gRPC-Web/Connect | connectrpc.com |
| Buf | Toolchain de construcción Protobuf, reemplaza protoc | buf.build |
| gRPC-Web | Protocolo gRPC del lado del navegador | connect-web |
| Protobuf | Lenguaje de definición de interfaces | google.golang.org/protobuf |
| Streaming | Streaming de Servidor/Cliente/Bidireccional | connect-go |
| Interceptor | Mecanismo de middleware de Connect | connect-go |
Cinco puntos de dolor: Por qué la comunicación frontend-backend tradicional no puede mantenerse al día
Punto de dolor 1: La pesadilla del mantenimiento de definiciones de API duales. El backend define servicios gRPC con Protobuf, el frontend define APIs REST con interfaces TypeScript — dos conjuntos de definiciones que nunca están sincronizados.
Punto de dolor 2: La documentación de Swagger es inútil. La documentación OpenAPI mantenida manualmente siempre va por detrás del código real. Los desarrolladores del frontend constantemente depuran contra documentación desactualizada.
Punto de dolor 3: Soluciones de streaming fragmentadas. gRPC Streaming no está disponible en navegadores, lo que fuerza la adopción de WebSocket y conduce a la fragmentación del protocolo.
Punto de dolor 4: Manejo de errores inconsistente. gRPC usa Status Codes, REST usa HTTP Status Codes — el frontend necesita dos conjuntos de lógica de manejo de errores.
Punto de dolor 5: Toolchain de generación de código caótica. Conflictos de versiones de plugins protoc, estilos de código generado inconsistentes y difícil integración con CI/CD.
Patrón 1: Definición de servicios Connect-RPC
Define servicios usando Buf y Connect-RPC — un Proto genera tanto código de backend Go como código de frontend TypeScript.
// Runtime: Buf v1.47+, connect-go v1.18+, protoc-gen-go v1.34+
// File: proto/order/v1/order.proto
syntax = "proto3";
package order.v1;
option go_package = "github.com/example/gen/order/v1;orderv1";
service OrderService {
rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse) {}
rpc GetOrder(GetOrderRequest) returns (GetOrderResponse) {}
rpc StreamOrders(StreamOrdersRequest) returns (stream Order) {}
rpc UploadOrders(stream UploadOrderRequest) returns (UploadOrdersResponse) {}
rpc OrderChat(stream ChatMessage) returns (stream ChatMessage) {}
}
message CreateOrderRequest {
string user_id = 1;
repeated OrderItem items = 2;
string shipping_address = 3;
}
message CreateOrderResponse {
string order_id = 1;
string status = 2;
int64 created_at = 3;
}
message GetOrderRequest {
string order_id = 1;
}
message GetOrderResponse {
Order order = 1;
}
message Order {
string order_id = 1;
string user_id = 2;
repeated OrderItem items = 3;
string status = 4;
int64 created_at = 5;
int64 updated_at = 6;
}
message OrderItem {
string product_id = 1;
string product_name = 2;
int32 quantity = 3;
double price = 4;
}
message StreamOrdersRequest {
string user_id = 1;
}
message UploadOrderRequest {
string user_id = 1;
repeated OrderItem items = 2;
}
message UploadOrdersResponse {
int32 total_created = 1;
repeated string order_ids = 2;
}
message ChatMessage {
string sender = 1;
string message = 2;
int64 timestamp = 3;
}
# File: buf.yaml
version: v2
modules:
- path: proto
name: buf.build/example/orders
lint:
use:
- STANDARD
breaking:
use:
- FILE
# File: buf.gen.yaml
version: v2
managed:
enabled: true
override:
- file_option: go_package_prefix
value: github.com/example/gen
plugins:
- remote: buf.build/connectrpc/go:v1.18.0
out: gen/go
opt: paths=source_relative
- remote: buf.build/protocolbuffers/go:v1.34.0
out: gen/go
opt: paths=source_relative
- remote: buf.build/connectrpc/es:v2.0.0
out: gen/ts
- remote: buf.build/bufbuild/es:v2.0.0
out: gen/ts
// Runtime: Go 1.22+, connect-go v1.18.0
// File: server/main.go
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
"connectrpc.com/connect"
"connectrpc.com/grpcreflect"
"github.com/example/gen/order/v1/orderv1connect"
orderpb "github.com/example/gen/order/v1"
)
// OrderServiceHandler implements the order service
type OrderServiceHandler struct {
orders map[string]*orderpb.Order
}
// CreateOrder creates a new order
func (h *OrderServiceHandler) CreateOrder(
ctx context.Context,
req *connect.Request[orderpb.CreateOrderRequest],
) (*connect.Response[orderpb.CreateOrderResponse], error) {
msg := req.Msg
orderID := fmt.Sprintf("ord-%d", time.Now().UnixNano())
order := &orderpb.Order{
OrderId: orderID,
UserId: msg.UserId,
Items: msg.Items,
Status: "CREATED",
CreatedAt: time.Now().Unix(),
UpdatedAt: time.Now().Unix(),
}
h.orders[orderID] = order
resp := connect.NewResponse(&orderpb.CreateOrderResponse{
OrderId: orderID,
Status: "CREATED",
CreatedAt: order.CreatedAt,
})
resp.Header().Set("X-Request-Id", fmt.Sprintf("req-%d", time.Now().UnixNano()))
return resp, nil
}
// GetOrder retrieves an order by ID
func (h *OrderServiceHandler) GetOrder(
ctx context.Context,
req *connect.Request[orderpb.GetOrderRequest],
) (*connect.Response[orderpb.GetOrderResponse], error) {
order, exists := h.orders[req.Msg.OrderId]
if !exists {
return nil, connect.NewError(connect.CodeNotFound,
fmt.Errorf("order %s not found", req.Msg.OrderId))
}
resp := connect.NewResponse(&orderpb.GetOrderResponse{
Order: order,
})
return resp, nil
}
// StreamOrders streams orders to the client
func (h *OrderServiceHandler) StreamOrders(
ctx context.Context,
req *connect.Request[orderpb.StreamOrdersRequest],
stream *connect.ServerStream[orderpb.Order],
) error {
for _, order := range h.orders {
if order.UserId == req.Msg.UserId {
if err := stream.Send(order); err != nil {
return fmt.Errorf("failed to stream order: %w", err)
}
time.Sleep(100 * time.Millisecond)
}
}
return nil
}
// UploadOrders handles client streaming upload
func (h *OrderServiceHandler) UploadOrders(
ctx context.Context,
stream *connect.ClientStream[orderpb.UploadOrderRequest],
) (*connect.Response[orderpb.UploadOrdersResponse], error) {
var totalCreated int32
var orderIDs []string
for stream.Receive() {
msg := stream.Msg()
orderID := fmt.Sprintf("ord-%d", time.Now().UnixNano())
order := &orderpb.Order{
OrderId: orderID,
UserId: msg.UserId,
Items: msg.Items,
Status: "CREATED",
CreatedAt: time.Now().Unix(),
}
h.orders[orderID] = order
totalCreated++
orderIDs = append(orderIDs, orderID)
}
if stream.Err() != nil {
return nil, connect.NewError(connect.CodeInternal, stream.Err())
}
resp := connect.NewResponse(&orderpb.UploadOrdersResponse{
TotalCreated: totalCreated,
OrderIds: orderIDs,
})
return resp, nil
}
// OrderChat handles bidirectional streaming chat
func (h *OrderServiceHandler) OrderChat(
ctx context.Context,
stream *connect.BidiStream[orderpb.ChatMessage, orderpb.ChatMessage],
) error {
for {
msg, err := stream.Receive()
if err != nil {
return nil
}
reply := &orderpb.ChatMessage{
Sender: "system",
Message: fmt.Sprintf("Received from %s: %s", msg.Sender, msg.Message),
Timestamp: time.Now().Unix(),
}
if err := stream.Send(reply); err != nil {
return fmt.Errorf("failed to send reply: %w", err)
}
}
}
func main() {
handler := &OrderServiceHandler{
orders: make(map[string]*orderpb.Order),
}
mux := http.NewServeMux()
path, orderHandler := orderv1connect.NewOrderServiceHandler(handler)
mux.Handle(path, orderHandler)
reflector := grpcreflect.NewStaticReflector(
orderv1connect.OrderServiceName,
)
mux.Handle(grpcreflect.NewHandlerV1(reflector))
mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector))
log.Println("Connect-RPC server starting on :8080")
log.Println("Supported protocols: gRPC, gRPC-Web, Connect")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Patrón 2: Llamadas Frontend gRPC-Web
Usa Connect-Web para llamar servicios gRPC directamente desde el frontend, eliminando la capa de middleware REST.
// Runtime: TypeScript 5.5+, @connectrpc/connect-web v2.0.0
// File: frontend/src/client.ts
import { createConnectTransport } from "@connectrpc/connect-web";
import { createClient } from "@connectrpc/connect";
import { OrderService } from "../gen/ts/order/v1/order_pb";
// Create Connect transport (HTTP/1.1 compatible)
const connectTransport = createConnectTransport({
baseUrl: "https://api.example.com",
});
// Create gRPC-Web transport
const grpcWebTransport = createConnectTransport({
baseUrl: "https://api.example.com",
httpVersion: "2",
});
// Create order service client
const orderClient = createClient(OrderService, connectTransport);
// === Unary call ===
async function createOrder() {
try {
const response = await orderClient.createOrder({
userId: "user-123",
items: [
{ productId: "prod-1", productName: "Go Programming", quantity: 1, price: 89.0 },
{ productId: "prod-2", productName: "Rust in Action", quantity: 2, price: 99.0 },
],
shippingAddress: "123 Main St, San Francisco",
});
console.log("Order created:", response.orderId, response.status);
} catch (err) {
console.error("Failed to create order:", err.code, err.message);
}
}
// === Get order ===
async function getOrder(orderId: string) {
try {
const response = await orderClient.getOrder({ orderId });
console.log("Order details:", response.order);
} catch (err: any) {
if (err.code === "NOT_FOUND") {
console.warn("Order not found");
} else {
console.error("Failed to get order:", err.message);
}
}
}
// === Server Streaming ===
async function streamOrders(userId: string) {
try {
for await (const order of orderClient.streamOrders({ userId })) {
console.log("Real-time order update:", order.orderId, order.status);
updateOrderInUI(order);
}
} catch (err) {
console.error("Order stream interrupted:", err.message);
}
}
// === Client Streaming ===
async function uploadOrders() {
const orders = [
{ userId: "user-1", items: [{ productId: "p1", productName: "Item 1", quantity: 1, price: 10 }] },
{ userId: "user-2", items: [{ productId: "p2", productName: "Item 2", quantity: 2, price: 20 }] },
];
try {
const response = await orderClient.uploadOrders(orders);
console.log(`Successfully uploaded ${response.totalCreated} orders`);
} catch (err) {
console.error("Failed to upload orders:", err.message);
}
}
function useOrderService() {
return { createOrder, getOrder, streamOrders, uploadOrders };
}
function updateOrderInUI(order: any) {}
Patrón 3: Manejo de errores y reintentos
Connect-RPC proporciona un mecanismo unificado de manejo de errores — tanto el frontend como el backend usan los mismos códigos de error y formato de mensaje.
// Runtime: Go 1.22+, connect-go v1.18.0
// File: server/errors.go
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
"connectrpc.com/connect"
)
// OrderError represents a business error
type OrderError struct {
Code connect.Code `json:"code"`
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
Retryable bool `json:"retryable"`
}
func (e *OrderError) Error() string {
return fmt.Sprintf("[%s] %s: %s", e.Code, e.Message, e.Detail)
}
// ToConnectError converts to a Connect error
func (e *OrderError) ToConnectError() *connect.Error {
err := connect.NewError(e.Code, fmt.Errorf("%s: %s", e.Message, e.Detail))
if e.Retryable {
err.Meta().Set("Retry-After", "5")
err.Meta().Set("X-Retryable", "true")
}
return err
}
var (
ErrOrderNotFound = &OrderError{
Code: connect.CodeNotFound,
Message: "Order not found",
Retryable: false,
}
ErrOrderAlreadyCancelled = &OrderError{
Code: connect.CodeFailedPrecondition,
Message: "Order already cancelled",
Retryable: false,
}
ErrInsufficientStock = &OrderError{
Code: connect.CodeResourceExhausted,
Message: "Insufficient stock",
Retryable: true,
}
ErrPaymentTimeout = &OrderError{
Code: connect.CodeDeadlineExceeded,
Message: "Payment timeout",
Retryable: true,
}
)
// RetryInterceptor implements client-side retry logic
type RetryInterceptor struct {
maxRetries int
initialDelay time.Duration
maxDelay time.Duration
retryableCodes map[connect.Code]bool
}
func NewRetryInterceptor() *RetryInterceptor {
return &RetryInterceptor{
maxRetries: 3,
initialDelay: 100 * time.Millisecond,
maxDelay: 5 * time.Second,
retryableCodes: map[connect.Code]bool{
connect.CodeUnavailable: true,
connect.CodeResourceExhausted: true,
connect.CodeDeadlineExceeded: true,
connect.CodeAborted: true,
},
}
}
func (i *RetryInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
var lastErr error
for attempt := 0; attempt <= i.maxRetries; attempt++ {
resp, err := next(ctx, req)
if err == nil {
return resp, nil
}
connectErr, ok := err.(*connect.Error)
if !ok {
return nil, err
}
if !i.retryableCodes[connectErr.Code()] {
return nil, err
}
retryAfter := connectErr.Meta().Get("Retry-After")
delay := i.calculateDelay(attempt, retryAfter)
lastErr = err
log.Printf("Retry %d/%d, delay %v, error: %v",
attempt+1, i.maxRetries, delay, connectErr.Message())
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, lastErr
}
}
func (i *RetryInterceptor) calculateDelay(attempt int, retryAfter string) time.Duration {
if retryAfter != "" {
if d, err := time.ParseDuration(retryAfter + "s"); err == nil {
return d
}
}
delay := i.initialDelay * time.Duration(1<<uint(attempt))
if delay > i.maxDelay {
delay = i.maxDelay
}
return delay
}
// RecoveryInterceptor recovers from panics in handlers
func RecoveryInterceptor() connect.UnaryInterceptorFunc {
return func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (resp connect.AnyResponse, err error) {
defer func() {
if r := recover(); r != nil {
log.Printf("[PANIC] Handler panic: %v", r)
err = connect.NewError(connect.CodeInternal,
fmt.Errorf("internal server error, please retry later"))
}
}()
return next(ctx, req)
}
}
}
// ErrorLoggingInterceptor logs errors
func ErrorLoggingInterceptor() connect.UnaryInterceptorFunc {
return func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
startTime := time.Now()
resp, err := next(ctx, req)
if err != nil {
connectErr, ok := err.(*connect.Error)
if ok {
log.Printf("[ERROR] method=%s code=%s msg=%s duration=%v",
req.Spec().Procedure,
connectErr.Code(),
connectErr.Message(),
time.Since(startTime),
)
}
}
return resp, err
}
}
}
func main() {
mux := http.NewServeMux()
interceptors := []connect.Interceptor{
RecoveryInterceptor(),
ErrorLoggingInterceptor(),
}
_ = interceptors
log.Println("Error handling service starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Patrón 4: Comunicación por streaming
Connect-RPC soporta completamente tres modos de comunicación por streaming con definiciones Protobuf unificadas tanto para el frontend como para el backend.
// Runtime: Go 1.22+, connect-go v1.18.0
// File: server/streaming.go
package main
import (
"context"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"sync"
"time"
"connectrpc.com/connect"
)
// OrderStatusStream manages order status subscriptions
type OrderStatusStream struct {
subscribers map[string]chan *OrderStatusUpdate
mu sync.RWMutex
}
type OrderStatusUpdate struct {
OrderID string `json:"order_id"`
Status string `json:"status"`
Message string `json:"message"`
}
func NewOrderStatusStream() *OrderStatusStream {
return &OrderStatusStream{
subscribers: make(map[string]chan *OrderStatusUpdate),
}
}
func (s *OrderStatusStream) Subscribe(userID string) <-chan *OrderStatusUpdate {
s.mu.Lock()
defer s.mu.Unlock()
ch := make(chan *OrderStatusUpdate, 100)
s.subscribers[userID] = ch
return ch
}
func (s *OrderStatusStream) Unsubscribe(userID string) {
s.mu.Lock()
defer s.mu.Unlock()
if ch, ok := s.subscribers[userID]; ok {
close(ch)
delete(s.subscribers, userID)
}
}
func (s *OrderStatusStream) Publish(update *OrderStatusUpdate) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, ch := range s.subscribers {
select {
case ch <- update:
default:
log.Printf("Subscriber channel full, dropping update: %s", update.OrderID)
}
}
}
// BatchOrderImporter handles batch order imports
type BatchOrderImporter struct {
processedCount int
failedCount int
mu sync.Mutex
}
type OrderImportRequest struct {
UserID string `json:"user_id"`
Data string `json:"data"`
}
type BatchImportResult struct {
BatchID string `json:"batch_id"`
ProcessedCount int32 `json:"processed_count"`
FailedCount int32 `json:"failed_count"`
}
func (b *BatchOrderImporter) ProcessStream(
ctx context.Context,
stream *connect.ClientStream[OrderImportRequest],
) (*BatchImportResult, error) {
batchID := fmt.Sprintf("batch-%d", time.Now().UnixNano())
for stream.Receive() {
req := stream.Msg()
if err := b.processOne(ctx, req); err != nil {
b.mu.Lock()
b.failedCount++
b.mu.Unlock()
continue
}
b.mu.Lock()
b.processedCount++
b.mu.Unlock()
}
if stream.Err() != nil {
return nil, connect.NewError(connect.CodeInternal, stream.Err())
}
return &BatchImportResult{
BatchID: batchID,
ProcessedCount: int32(b.processedCount),
FailedCount: int32(b.failedCount),
}, nil
}
func (b *BatchOrderImporter) processOne(ctx context.Context, req *OrderImportRequest) error {
select {
case <-time.After(time.Duration(rand.Intn(50)) * time.Millisecond):
case <-ctx.Done():
return ctx.Err()
}
return nil
}
// CollaborationRoom manages real-time collaboration
type CollaborationRoom struct {
clients map[string]chan *CollabMessage
mu sync.RWMutex
}
type CollabMessage struct {
UserID string `json:"user_id"`
Content string `json:"content"`
Type string `json:"type"`
}
func (r *CollaborationRoom) Join(userID string) chan *CollabMessage {
r.mu.Lock()
defer r.mu.Unlock()
ch := make(chan *CollabMessage, 50)
r.clients[userID] = ch
return ch
}
func (r *CollaborationRoom) Leave(userID string) {
r.mu.Lock()
defer r.mu.Unlock()
if ch, ok := r.clients[userID]; ok {
close(ch)
delete(r.clients, userID)
}
}
func (r *CollaborationRoom) Broadcast(msg *CollabMessage, excludeUserID string) {
r.mu.RLock()
defer r.mu.RUnlock()
for userID, ch := range r.clients {
if userID == excludeUserID {
continue
}
select {
case ch <- msg:
default:
log.Printf("Client %s channel full", userID)
}
}
}
func (r *CollaborationRoom) HandleCollabStream(
ctx context.Context,
stream *connect.BidiStream[CollabMessage, CollabMessage],
) error {
userID := stream.RequestHeader().Get("X-User-Id")
if userID == "" {
userID = fmt.Sprintf("user-%d", rand.Int63())
}
recvCh := r.Join(userID)
defer r.Leave(userID)
errCh := make(chan error, 1)
go func() {
for {
msg, err := stream.Receive()
if err != nil {
if err == io.EOF {
errCh <- nil
return
}
errCh <- err
return
}
r.Broadcast(msg, userID)
}
}()
for {
select {
case msg := <-recvCh:
if err := stream.Send(msg); err != nil {
return fmt.Errorf("failed to send collab message: %w", err)
}
case err := <-errCh:
return err
case <-ctx.Done():
return ctx.Err()
}
}
}
func main() {
room := &CollaborationRoom{
clients: make(map[string]chan *CollabMessage),
}
statusStream := NewOrderStatusStream()
_ = room
_ = statusStream
log.Println("Streaming service starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Patrón 5: Gateway Connect listo para producción
Construye un gateway Connect-RPC listo para producción con autenticación, limitación de tasa, monitoreo y apagado graceful.
// Runtime: Go 1.22+, connect-go v1.18.0
// File: server/gateway.go
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
"connectrpc.com/connect"
"connectrpc.com/grpcreflect"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
func AuthInterceptor(jwtSecret string) connect.UnaryInterceptorFunc {
return func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
if strings.HasSuffix(req.Spec().Procedure, "/Health/Check") {
return next(ctx, req)
}
token := req.Header().Get("Authorization")
if token == "" {
return nil, connect.NewError(connect.CodeUnauthenticated,
fmt.Errorf("missing authentication token"))
}
token = strings.TrimPrefix(token, "Bearer ")
claims, err := validateJWT(token, jwtSecret)
if err != nil {
return nil, connect.NewError(connect.CodeUnauthenticated,
fmt.Errorf("invalid authentication token: %w", err))
}
ctx = context.WithValue(ctx, "userID", claims.UserID)
ctx = context.WithValue(ctx, "scopes", claims.Scopes)
return next(ctx, req)
}
}
}
type JWTClaims struct {
UserID string `json:"user_id"`
Scopes []string `json:"scopes"`
}
func validateJWT(token, secret string) (*JWTClaims, error) {
return &JWTClaims{
UserID: "user-from-token",
Scopes: []string{"orders:read", "orders:write"},
}, nil
}
type RateLimiter struct {
tokens atomic.Int64
maxTokens int64
refillRate time.Duration
}
func NewRateLimiter(maxTokens int64, refillRate time.Duration) *RateLimiter {
rl := &RateLimiter{
maxTokens: maxTokens,
refillRate: refillRate,
}
rl.tokens.Store(maxTokens)
go func() {
ticker := time.NewTicker(refillRate)
defer ticker.Stop()
for range ticker.C {
current := rl.tokens.Load()
if current < maxTokens {
rl.tokens.CompareAndSwap(current, current+1)
}
}
}()
return rl
}
func (rl *RateLimiter) Allow() bool {
for {
current := rl.tokens.Load()
if current <= 0 {
return false
}
if rl.tokens.CompareAndSwap(current, current-1) {
return true
}
}
}
func RateLimitInterceptor(limiter *RateLimiter) connect.UnaryInterceptorFunc {
return func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
if !limiter.Allow() {
return nil, connect.NewError(connect.CodeResourceExhausted,
fmt.Errorf("rate limit exceeded, please retry later"))
}
return next(ctx, req)
}
}
}
func TracingInterceptor() connect.UnaryInterceptorFunc {
return func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
startTime := time.Now()
traceID := req.Header().Get("X-Trace-Id")
if traceID == "" {
traceID = fmt.Sprintf("trace-%d", startTime.UnixNano())
}
log.Printf("[TRACE] id=%s method=%s start=%v",
traceID, req.Spec().Procedure, startTime)
resp, err := next(ctx, req)
duration := time.Since(startTime)
status := "OK"
if err != nil {
status = "ERROR"
}
log.Printf("[TRACE] id=%s method=%s status=%s duration=%v",
traceID, req.Spec().Procedure, status, duration)
if resp != nil {
resp.Header().Set("X-Trace-Id", traceID)
resp.Header().Set("X-Response-Time", duration.String())
}
return resp, err
}
}
}
type ConnectGateway struct {
server *http.Server
limiter *RateLimiter
shutdownCh chan os.Signal
}
func NewConnectGateway(addr string) *ConnectGateway {
gw := &ConnectGateway{
limiter: NewRateLimiter(1000, time.Second),
shutdownCh: make(chan os.Signal, 1),
}
mux := http.NewServeMux()
reflector := grpcreflect.NewStaticReflector()
mux.Handle(grpcreflect.NewHandlerV1(reflector))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
interceptors := []connect.Interceptor{
TracingInterceptor(),
RateLimitInterceptor(gw.limiter),
AuthInterceptor("your-jwt-secret"),
}
_ = interceptors
gw.server = &http.Server{
Addr: addr,
Handler: h2c.NewHandler(mux, &http2.Server{}),
}
return gw
}
func (gw *ConnectGateway) Start() error {
signal.Notify(gw.shutdownCh, syscall.SIGINT, syscall.SIGTERM)
errCh := make(chan error, 1)
go func() {
log.Printf("Connect gateway starting on %s", gw.server.Addr)
log.Println("Supported protocols: gRPC, gRPC-Web, Connect (HTTP/1.1 & HTTP/2)")
if err := gw.server.ListenAndServe(); err != http.ErrServerClosed {
errCh <- err
}
}()
select {
case err := <-errCh:
return fmt.Errorf("gateway failed to start: %w", err)
case sig := <-gw.shutdownCh:
log.Printf("Received signal %v, starting graceful shutdown...", sig)
return gw.Shutdown()
}
}
func (gw *ConnectGateway) Shutdown() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
log.Println("Stopping new request acceptance...")
if err := gw.server.Shutdown(ctx); err != nil {
return fmt.Errorf("gateway shutdown failed: %w", err)
}
log.Println("Gateway gracefully shut down")
return nil
}
func main() {
gateway := NewConnectGateway(":8080")
if err := gateway.Start(); err != nil {
log.Fatalf("Gateway failed: %v", err)
}
}
Guía de trampas: 5 trampas de nivel producción
Trampa 1: Conflictos de versiones en la generación de código de Buf. Diferentes versiones de plugins generan código incompatible, causando fallos de compilación. Solución: Bloquear versiones de plugins en buf.gen.yaml, usar buf generate en CI en lugar de protoc local.
Trampa 2: Fallo en la negociación HTTP/2. Algunos proxies inversos (versiones antiguas de Nginx) no soportan HTTP/2, causando fallos en llamadas gRPC. Solución: Usar el protocolo Connect (compatible con HTTP/1.1) como fallback, o actualizar proxies para soportar h2c.
Trampa 3: Desconexión de streams pasa desapercibida. La fluctuación de red causa caídas en conexiones de streaming sin que el servidor se entere. Solución: Implementar mecanismos de heartbeat (ping cada 30 segundos), establecer timeouts de lectura/escritura y auto-reconexión del cliente.
Trampa 4: Cuerpos de mensajes grandes causan OOM. La subida de archivos grandes por streaming llena el búfer del receptor, causando OOM. Solución: Establecer tamaño máximo del cuerpo del mensaje (el valor predeterminado de Connect es 4MB), procesar en chunks de streaming, implementar backpressure.
Trampa 5: Configuración CORS faltante. Las llamadas Connect del lado del navegador son bloqueadas por la política CORS. Solución: Configurar middleware CORS a nivel de gateway, permitiendo Content-Type: application/proto y application/json.
Referencia rápida de solución de errores
| Mensaje de error | Causa | Solución |
|---|---|---|
connect: code = Unauthenticated |
Token de autenticación faltante o inválido | Verificar header Authorization y validez del JWT |
connect: code = NotFound |
El recurso solicitado no existe | Verificar parámetros de solicitud e ID del recurso |
connect: code = ResourceExhausted |
Se excedió el límite de tasa de solicitudes | Reducir frecuencia de solicitudes o ajustar configuración de límite |
connect: code = Unavailable |
Servicio no disponible | Verificar salud del servicio y conectividad de red |
http2: frame too large |
El cuerpo del mensaje excede el límite predeterminado | Ajustar la opción connect.MaxRecvMsgSize |
CORS policy: No Access-Control-Allow-Origin |
Configuración CORS faltante | Agregar middleware CORS en el gateway |
proto: invalid wire format |
Desajuste en codificación/decodificación Protobuf | Verificar consistencia de versión del archivo Proto |
buf: plugin not found |
Plugin de Buf no instalado | Ejecutar buf generate con plugins remotos |
stream recv: context canceled |
El cliente canceló la solicitud o expiró el tiempo | Aumentar timeout o verificar lógica del cliente |
tls: handshake failure |
Configuración TLS incorrecta | Verificar configuración de certificados o usar h2c para desarrollo |
Optimización avanzada: 5 consejos de nivel producción
Consejo 1: Gestión de versiones de archivos Proto. Usar el BSR (Buf Schema Registry) de Buf para gestionar versiones de archivos Proto, habilitando versionado de API y verificaciones de compatibilidad hacia atrás.
Consejo 2: Estrategia de fallback del protocolo Connect. Selección automática de protocolo según la capacidad del cliente: gRPC (backend-a-backend), gRPC-Web (navegadores antiguos), Connect (navegadores modernos) — sin necesidad de cambio manual.
Consejo 3: Control de backpressure en streams. Implementar backpressure en streaming del servidor ajustando dinámicamente la tasa de push según la velocidad de consumo del cliente, previniendo desbordamiento del búfer.
Consejo 4: Agrupación de solicitudes. Usar Client Streaming de Connect para combinar múltiples solicitudes pequeñas en una solicitud de streaming, reduciendo viajes de ida y vuelta de red.
Consejo 5: Integración de observabilidad. Integrar OpenTelemetry en interceptores para generar automáticamente Spans y Métricas, incorporando cadenas de llamadas Connect en trazado distribuido completo.
Análisis comparativo
| Dimensión | REST + JSON | gRPC | gRPC-Connect |
|---|---|---|---|
| Protocolo | HTTP/1.1 | HTTP/2 | HTTP/1.1 + HTTP/2 |
| Formato de datos | JSON | Protobuf | Protobuf + JSON |
| Soporte de navegador | Nativo | Requiere gRPC-Web | Nativo (protocolo Connect) |
| Streaming | WebSocket | Nativo | Nativo |
| Generación de código | OpenAPI/Swagger | protoc | Buf |
| Seguridad de tipos | Débil (JSON) | Fuerte (Protobuf) | Fuerte (Protobuf) |
| Manejo de errores | Código de estado HTTP | Estado gRPC | Estado Connect (compatible con gRPC) |
| Curva de aprendizaje | Baja | Media | Media |
| Rendimiento | Medio | Alto | Alto |
| Unificación frontend-backend | No | No | Sí |
Conclusión
Los 5 patrones principales del protocolo gRPC-Connect resuelven los puntos de dolor clave de la comunicación frontend-backend de microservicios: la definición de servicios Connect-RPC permite un Proto para ambos lados, las llamadas frontend gRPC-Web eliminan la capa de middleware REST, el manejo de errores unificado y los mecanismos de reintento aseguran la confiabilidad, la comunicación por streaming proporciona capacidades completas de comunicación en tiempo real y el gateway Connect listo para producción asegura la estabilidad del sistema.
gRPC-Connect no es un reemplazo de REST — es la mejor práctica para la comunicación unificada frontend-backend. Si estás construyendo un sistema de microservicios Go, el ecosistema gRPC-Connect + Buf es el stack tecnológico en el que vale la pena invertir en 2026. Recuerda: una definición, ambos lados funcionan — ese es el valor central de gRPC-Connect.
Herramientas en línea recomendadas
- /en/json/format — Formateador JSON para visualizar solicitudes y respuestas JSON del protocolo Connect
- /en/dev/curl-to-code — Convertidor de cURL a código para generar rápidamente código de llamadas de cliente Connect
- /en/encode/hash — Calculadora de hash para verificar resúmenes de mensajes Protobuf
- /en/text/diff — Herramienta de diff de texto para comparar cambios en archivos Proto entre versiones
Prueba estas herramientas que se ejecutan en tu navegador — no requieren registro →