Ai

Implémenter des services gRPC prêts pour la production : timeouts, intercepteurs et observabilité

Damien LarqueyDamien Larquey
February 22, 2026
8 min read
Implémenter des services gRPC prêts pour la production : timeouts, intercepteurs et observabilité

Objectif

Ce guide décrit comment implémenter des services gRPC réellement prêts pour la production en se concentrant sur trois axes critiques : gestion rigoureuse des timeouts, intercepteurs (middleware) et observabilité moderne. Les exemples sont en Go, mais les concepts sont directement transposables à Java, .NET, Rust, etc.

À la fin, vous disposerez d’un squelette de service gRPC :

  • avec des timeouts cohérents et propagés de bout en bout,
  • des intercepteurs pour logs, auth, limites de temps et métrologie,
  • et une intégration OpenTelemetry pour traces et métriques.

Temps estimé pour adapter ces patterns à un service existant : 1 à 2 heures, hors mise en place du backend de monitoring.

Prérequis

  • Maîtrise de base de gRPC et Protobuf (définition de services, génération de stubs).
  • Go 1.20+ installé (go, protoc, protoc-gen-go, protoc-gen-go-grpc).
  • Un service gRPC simple existant (ou un squelette) à instrumenter.
  • Un backend d’observabilité :
    • soit un stack auto-hébergé (Prometheus + Grafana, Jaeger/Tempo),
    • soit une solution SaaS compatible OpenTelemetry (Datadog, Grafana Cloud, etc.).
  • Certificats TLS disponibles si vous exposez le service hors d’un mesh sécurisé.
Flux gRPC avec timeouts, intercepteurs et observabilité OpenTelemetry à travers plusieurs services

Mise en œuvre pas à pas

Étape 1 – Concevoir une stratégie de timeouts et de deadlines

En gRPC, un appel sans deadline explicite peut rester bloqué très longtemps en cas de problème réseau ou de dépendance lente. En production, tout appel doit avoir une deadline côté client, idéalement dérivée d’un SLA ou d’un SLO par méthode.

Côté client Go, on utilise typiquement context.WithTimeout :

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

resp, err := userClient.GetUser(ctx, &pb.GetUserRequest{Id: "123"})
if err != nil {
    st, _ := status.FromError(err)
    log.Printf("appel GetUser échoué: code=%s err=%v", st.Code(), err)
}

Principe architectural important : le client est responsable de la deadline, le serveur la respecte et peut éventuellement la raccourcir (timeout maximum côté serveur).

Sur le serveur, vous pouvez ajouter un intercepteur pour imposer un timeout maximum tout en respectant la deadline client. Exemple :

Implémenter des services gRPC prêts pour la production : timeouts, intercepteurs et observabilité
func timeoutInterceptor(maxTimeout time.Duration) grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req interface{},
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (interface{}, error) {

        // deadline éventuellement déjà fournie par le client
        if dl, ok := ctx.Deadline(); ok {
            remaining := time.Until(dl)
            if remaining <= 0 {
                return nil, status.Error(codes.DeadlineExceeded, "deadline client dépassée")
            }
            // si le client a déjà un timeout plus court que maxTimeout, ne pas l'étendre
            if remaining < maxTimeout {
                return handler(ctx, req)
            }
        }

        // sinon, ou si la deadline est trop longue, on impose maxTimeout
        ctx, cancel := context.WithTimeout(ctx, maxTimeout)
        defer cancel()

        resp, err := handler(ctx, req)

        if errors.Is(ctx.Err(), context.DeadlineExceeded) {
            return nil, status.Error(codes.DeadlineExceeded, "timeout serveur atteint")
        }

        return resp, err
    }
}

Lors de la création du serveur :

srv := grpc.NewServer(
    grpc.UnaryInterceptor(timeoutInterceptor(1500 * time.Millisecond)),
)

Dans vos handlers, utilisez systématiquement ctx pour toute opération potentiellement bloquante (appels externes, DB, etc.) et vérifiez régulièrement ctx.Err(). Cela permet une annulation rapide quand la requête est abandonnée par l’appelant.

Étape 2 – Ajouter des intercepteurs (logging, auth, robustesse)

Les intercepteurs gRPC jouent le rôle de middleware : ils enveloppent l’exécution des handlers et vous permettent d’implémenter des préoccupations transverses (auth, logs, quotas, retries, etc.) de manière cohérente.

En Go, vous avez :

  • grpc.UnaryServerInterceptor pour les RPC simples,
  • grpc.StreamServerInterceptor pour les flux.

Un intercepteur de logging structuré typique avec Zap peut ressembler à ceci :

func loggingInterceptor(logger *zap.Logger) grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req interface{},
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (interface{}, error) {

        start := time.Now()

        // récupération de l'adresse du client
        peerInfo, _ := peer.FromContext(ctx)
        clientAddr := ""
        if peerInfo != nil {
            clientAddr = peerInfo.Addr.String()
        }

        resp, err := handler(ctx, req)
        duration := time.Since(start)

        st, _ := status.FromError(err)
        logger.Info("grpc_unary",
            zap.String("method", info.FullMethod),
            zap.Duration("duration", duration),
            zap.String("client", clientAddr),
            zap.String("code", st.Code().String()),
            zap.Error(err),
        )

        return resp, err
    }
}

Pour l’authentification, un intercepteur lit typiquement le header authorization dans les métadonnées et ajoute une identité dans le context pour les handlers :

Implémenter des services gRPC prêts pour la production : timeouts, intercepteurs et observabilité
func authInterceptor(validator TokenValidator) grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req interface{},
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (interface{}, error) {

        md, ok := metadata.FromIncomingContext(ctx)
        if !ok {
            return nil, status.Error(codes.Unauthenticated, "métadonnées manquantes")
        }

        authz := md.Get("authorization")
        if len(authz) == 0 {
            return nil, status.Error(codes.Unauthenticated, "jeton manquant")
        }

        user, err := validator.Validate(authz[0])
        if err != nil {
            return nil, status.Error(codes.Unauthenticated, "jeton invalide")
        }

        ctx = context.WithValue(ctx, userContextKey{}, user)
        return handler(ctx, req)
    }
}

En production, on chaine plusieurs intercepteurs : timeout, auth, logging, métrologie, récupération de panic, etc. Avec go-grpc-middleware, par exemple :

srv := grpc.NewServer(
    grpc_middleware.ChainUnaryServer(
        otelgrpc.UnaryServerInterceptor(),
        timeoutInterceptor(1500*time.Millisecond),
        authInterceptor(tokenValidator),
        loggingInterceptor(logger),
        recoveryInterceptor(), // convertit les panics en erreurs gRPC
    ),
)

Point d’attention : les intercepteurs doivent être rapides et non bloquants. Évitez d’y mettre des appels réseau synchrones (par exemple vers une base ou un service distant) ou encapsulez-les avec des caches et des timeouts très courts.

Étape 3 – Intégrer l’observabilité moderne (OpenTelemetry)

Les développements récents autour d’OpenTelemetry ont stabilisé l’instrumentation gRPC. Il est aujourd’hui raisonnable d’utiliser OTel comme couche unique pour traces et métriques, avec export vers votre backend préféré.

Implémenter des services gRPC prêts pour la production : timeouts, intercepteurs et observabilité

Installez d’abord les dépendances côté Go :

go get go.opentelemetry.io/otel \
       go.opentelemetry.io/otel/sdk/trace \
       go.opentelemetry.io/otel/sdk/metric \
       go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc

Initialisez ensuite un TracerProvider (par exemple, export OTLP vers un Collector) :

func initTracerProvider(endpoint string) (*sdktrace.TracerProvider, error) {
    exporter, err := otlptracegrpc.New(context.Background(),
        otlptracegrpc.WithEndpoint(endpoint),
        otlptracegrpc.WithInsecure(), // en prod: TLS ou via mesh
    )
    if err != nil {
        return nil, err
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceName("user-service"),
        )),
    )

    otel.SetTracerProvider(tp)
    return tp, nil
}

Côté serveur gRPC, activez l’intercepteur OTel :

srv := grpc.NewServer(
    grpc_middleware.ChainUnaryServer(
        otelgrpc.UnaryServerInterceptor(
            otelgrpc.WithTracerProvider(otel.GetTracerProvider()),
        ),
        timeoutInterceptor(1500*time.Millisecond),
        authInterceptor(tokenValidator),
        loggingInterceptor(logger),
    ),
)

Côté client, faites de même pour propager correctement le contexte de trace :

conn, err := grpc.DialContext(
    ctx,
    "user-service:50051",
    grpc.WithTransportCredentials(creds),
    grpc.WithUnaryInterceptor(
        otelgrpc.UnaryClientInterceptor(
            otelgrpc.WithTracerProvider(otel.GetTracerProvider()),
        ),
    ),
)

Les intercepteurs OTel ajoutent automatiquement les attributs standard :

  • rpc.system = "grpc"
  • rpc.service et rpc.method
  • rpc.grpc.status_code

Pour la corrélation logs ↔ traces, ajoutez les identifiants de trace dans votre logger :

span := trace.SpanFromContext(ctx)
spanCtx := span.SpanContext()
logger = logger.With(
    zap.String("trace_id", spanCtx.TraceID().String()),
    zap.String("span_id", spanCtx.SpanID().String()),
)

Avec cette configuration, une requête qui traverse plusieurs services gRPC sera visible comme une trace unique dans votre UI (Jaeger, Tempo, Datadog, etc.), avec pour chaque span les attributs gRPC et la durée.

Vérification

Pour vérifier que l’implémentation est correcte :

  • Timeouts :
    • Créez une méthode gRPC qui sleep au-delà du timeout max serveur.
    • Appelez-la avec grpcurl ou un client de test avec un timeout plus long.
    • Vous devez recevoir DEADLINE_EXCEEDED au bout du timeout serveur, pas plus.
  • Intercepteurs :
    • Vérifiez que vos logs contiennent bien method, duration, code, client.
    • Appelez une méthode avec un token invalide : le code doit être UNAUTHENTICATED et le handler ne doit pas s’exécuter.
  • Observabilité :
    • Générez quelques requêtes et ouvrez votre UI de traces : une trace par requête doit apparaître.
    • Filtrez par service.name = user-service : vous devez voir les spans de votre service avec les méthodes gRPC en clair.
    • Vérifiez que les métriques RPC (latences, taux d’erreurs) remontent dans vos dashboards.

Dépannage

  • Les requêtes semblent “geler” :
    • Assurez-vous que tous les clients fixent une deadline (WithTimeout ou équivalent).
    • Activez des timeouts serveurs raisonnables et vérifiez que vos handlers utilisent bien ctx.
    • Regardez les traces : un span excessivement long pointe souvent vers une dépendance lente.
  • Beaucoup de DEADLINE_EXCEEDED :
    • Vos timeouts sont peut-être trop agressifs par rapport aux latences réelles.
    • Mesurez les P95/P99 avant de réduire les SLA ; optimisez ensuite le code ou le schéma d’appel.
    • Surveillez particulièrement les appels en cascade (N+1 services).
  • Pas de traces ou métriques visibles :
    • Vérifiez que le TracerProvider est initialisé avant la création du serveur/client.
    • Assurez-vous que l’OTel Collector est joignable (host/port, TLS, pare-feu).
    • Vérifiez que vous n’écrasez pas le context en utilisant context.Background() dans les handlers au lieu du ctx reçu.
  • Panics dans les intercepteurs :
    • Ajoutez un recoveryInterceptor pour transformer les panics en INTERNAL et loguer la stack-trace.
    • Évitez d’y faire de la logique métier complexe ; gardez-les fins.
  • Fuite mémoire ou goroutines :
    • Vérifiez que tous les context.WithTimeout / WithCancel sont associés à un defer cancel().
    • Ne créez pas de goroutines qui ignorent les annulations de ctx.

Prochaines étapes et considérations opérationnelles

Une fois ces fondations en place, les améliorations naturelles pour une exploitation à grande échelle sont :

  • Configuration centralisée des timeouts :
    • Stockez les timeouts par méthode dans un fichier de configuration ou un store (par ex. GetUser: 500ms, ListUsers: 2s).
    • Chargez dynamiquement ces valeurs pour éviter les déploiements à chaque ajustement.
  • SLOs et alerting :
    • Définissez des SLO par méthode (GetUser P95 < 150ms, taux d’erreur < 0,1 %).
    • Créez des alertes sur les ruptures de SLO plutôt que sur des métriques brutes.
  • Tests d’intégration et de charge :
    • Ajoutez des tests automatisés qui vérifient les codes d’erreur gRPC et le respect des timeouts.
    • Faites du chaos engineering ciblé (injection de latence, pannes) pour valider la robustesse de la chaîne de timeouts.
  • Évolution des intercepteurs :
    • Versionnez vos intercepteurs critiques (auth, quotas) et déployez-les progressivement.
    • Surveillez leur impact de performance : un intercepteur mal conçu peut devenir un goulot d’étranglement.
  • Intégration service mesh / xDS :
    • Si vous utilisez un service mesh (Envoy, Istio), tirez parti des timeouts et du circuit breaking côté proxy en complément de vos timeouts applicatifs.
    • Gardez néanmoins des timeouts au niveau applicatif pour éviter une dépendance totale à la configuration mesh.

En combinant timeouts bien pensés, intercepteurs structurés et observabilité OpenTelemetry, vos services gRPC deviennent non seulement plus robustes, mais surtout diagnosticables. Ce sont ces propriétés qui feront la différence lorsqu’un incident de production surviendra à 3 h du matin.

Damien Larquey

Damien Larquey

Author at Codolie

Passionate about technology, innovation, and sharing knowledge with the developer community.

Back to Blog