Dans les architectures de microservices modernes, la gestion des délais d'attente (timeouts) est fondamentale pour garantir la robustesse et la réactivité des systèmes distribués. Sans délais d'attente appropriés, une requête bloquée ou un service lent peut consommer des ressources précieuses indéfiniment, entraînant des latences accrues pour d'autres requêtes, voire une dégradation complète du service. gRPC, par défaut, utilise des délais d'attente généreux, ce qui rend la configuration explicite des durées de validité essentielle.
Cet article explore comment implémenter et gérer les délais d'attente pour les appels de services gRPC en utilisant le langage Go. Nous aborderons à la fois la configuration côté client et la réaction appropriée côté serveur.
Définition du service Protobuf
Commençons par définir notre service gRPC à l'aide de Protocol Buffers. Ce fichier .proto décrit la structure de notre service, les messages de requête et de réponse, ainsi que les méthodes disponibles.
syntax = "proto3";
package greeter;
option go_package = "./greeter;greeter";
// Définition d'un service de salutation
service GreeterService {
// Méthode pour saluer quelqu'un
rpc SayHello (HelloRequest) returns (HelloResponse) {}
}
// Message de requête pour la salutation
message HelloRequest {
string name = 1; // Nom de la personne à saluer
}
// Message de réponse pour la salutation
message HelloResponse {
string message = 1; // Le message de salutation
int32 status_code = 2; // Code de statut de la réponse
}
Après avoir défini le fichier greeter.proto, nous générons le code Go correspondant :
protoc --go_out=plugins=grpc:. greeter.proto
Configuration du Délai d'Attente Côté Client
Le client gRPC est responsable de l'initialisation du contexte de requête avec un délai d'attente. Ce contexte est ensuite transmis à l'appel de la méthode distante. Si le délai expire avant que le serveur ne réponde, l'appel sera automatiquement annulé côté client.
Initialisation du conetxte avec un délai
En Go, le package context est utilisé pour gérer les délais et les annulations. Nous pouvons créer un contexte dérivé qui expirera après une durée spécifiée à l'aide de context.WithTimeout.
// Définir un délai de 3 secondes pour l'appel
ctxWithTimeout, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() // S'assurer que la fonction cancel est appelée pour libérer les ressources
// ... appel gRPC utilisant ctxWithTimeout
Gestion des erreurs de délai
Lorsqu'un délai expire, l'appel gRPC renverra une erreur. Il est crucial d'inspecter cette erreur pour déterminer si elle est due à une expiration de délai ou à une autre cause.
// Appel du service gRPC avec le contexte à durée limitée
resp, err := greeterClient.SayHello(ctxWithTimeout, &reqPayload)
if err != nil {
// Tenter de récupérer l'état de l'erreur gRPC
st, ok := status.FromError(err)
if ok && st.Code() == codes.DeadlineExceeded {
log.Fatalf("Erreur : L'appel au service a expiré !")
}
log.Fatalf("Erreur lors de l'appel SayHello : %v", err)
}
log.Printf("Réponse du serveur : %s", resp.GetMessage())
Exemple complet de client Go
Voici un client gRPC complet qui configure un délai d'attente et gère l'erreur correspondante.
package main
import (
"context"
pb "demo/greeter" // Assurez-vous que le chemin d'importation correspond à votre structure de projet
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
serverAddress = "localhost:8000"
)
func main() {
// Établir une connexion sécurisée avec le serveur gRPC
conn, err := grpc.Dial(serverAddress, grpc.WithInsecure())
if err != nil {
log.Fatalf("Impossible de se connecter au serveur gRPC : %v", err)
}
defer conn.Close()
// Créer un client pour le service Greeter
greeterClient := pb.NewGreeterServiceClient(conn)
// Appeler le service avec un délai d'attente de 2 secondes
performGreetingCall(greeterClient, 2*time.Second)
// Appeler le service avec un délai d'attente de 5 secondes
performGreetingCall(greeterClient, 5*time.Second)
}
// performGreetingCall effectue un appel SayHello avec un délai d'attente spécifié.
func performGreetingCall(client pb.GreeterServiceClient, timeoutDuration time.Duration) {
ctx := context.Background()
// Créer un contexte avec un délai d'attente
ctxWithTimeout, cancel := context.WithTimeout(ctx, timeoutDuration)
defer cancel() // Annuler le contexte pour libérer les ressources associées
// Préparer la requête
reqPayload := &pb.HelloRequest{Name: "Utilisateur gRPC"}
log.Printf("Tentative d'appel SayHello avec un délai de %s...", timeoutDuration)
// Effectuer l'appel RPC
resp, err := client.SayHello(ctxWithTimeout, reqPayload)
if err != nil {
st, ok := status.FromError(err)
if ok && st.Code() == codes.DeadlineExceeded {
log.Printf("L'appel SayHello a expiré après %s !", timeoutDuration)
return
}
log.Fatalf("Erreur inattendue lors de l'appel SayHello après %s : %v", timeoutDuration, err)
}
log.Printf("Réponse du serveur reçue (délai %s) : '%s' (Statut: %d)", timeoutDuration, resp.GetMessage(), resp.GetStatusCode())
}
Détection de l'Annulation Côté Serveur
Lorsqu'un client annule une requête (par exemple, en raison d'un délai d'attente), le contexte de la requête côté serveur reçoit un signal d'annulation. Le serveur doit écouter ce signal et interrompre toute opération en cours pour éviter de gaspiller des ressources.
Pour simuler une opération longue, nous utiliserons une goroutine séparée et un canal pour communiquer le résultat, ou une instruction select pour réagir soit à la fin de l'opération, soit à l'annulation du contexte.
package main
import (
"context"
pb "demo/greeter" // Assurez-vous que le chemin d'importation correspond à votre structure de projet
"log"
"net"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// GreeterServer implémente le service GreeterService défini dans le fichier .proto.
type GreeterServer struct{}
const (
listenAddress = ":8000"
networkType = "tcp"
)
func main() {
// Écouter sur l'adresse spécifiée
listener, err := net.Listen(networkType, listenAddress)
if err != nil {
log.Fatalf("Échec de l'écoute sur %s : %v", listenAddress, err)
}
log.Printf("Serveur gRPC écoutant sur %s...", listenAddress)
// Créer une nouvelle instance de serveur gRPC
grpcServer := grpc.NewServer()
// Enregistrer notre implémentation de service
pb.RegisterGreeterServiceServer(grpcServer, &GreeterServer{})
// Démarrer le serveur et le maintenir bloqué jusqu'à ce que l'application s'arrête
if err := grpcServer.Serve(listener); err != nil {
log.Fatalf("Échec du démarrage du serveur gRPC : %v", err)
}
}
// SayHello implémente la méthode SayHello du service GreeterService.
func (s *GreeterServer) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloResponse, error) {
log.Printf("Réception d'une requête SayHello de '%s'.", req.GetName())
resultChan := make(chan *pb.HelloResponse, 1)
// Simuler une opération potentiellement longue dans une goroutine séparée
go simulateLongRunningOperation(ctx, req, resultChan)
select {
case res := <-resultChan:
log.Printf("Opération terminée pour '%s'. Envoi de la réponse.", req.GetName())
return res, nil
case <-ctx.Done():
// Le client a annulé la requête (par exemple, à cause d'un délai d'attente)
log.Printf("Contexte annulé pour la requête de '%s': %v", req.GetName(), ctx.Err())
return nil, status.Errorf(codes.Canceled, "Le client a annulé la requête : %v", ctx.Err())
}
}
// simulateLongRunningOperation simule un travail qui prend du temps.
func simulateLongRunningOperation(ctx context.Context, req *pb.HelloRequest, resChan chan<- *pb.HelloResponse) {
// Nous simulons une opération de 3 secondes.
// Si le délai du client est inférieur à cela, la requête sera annulée.
processingTime := 3 * time.Second
select {
case <-ctx.Done():
log.Printf("L'opération pour '%s' a été annulée pendant son exécution.", req.GetName())
// Le contexte a été annulé, nous arrêtons le traitement.
return
case <-time.After(processingTime):
// L'opération est terminée.
response := &pb.HelloResponse{
StatusCode: 200,
Message: "Bonjour, " + req.GetName() + " depuis le serveur !",
}
log.Printf("Opération pour '%s' terminée après %s.", req.GetName(), processingTime)
resChan <- response
}
}
Démonstration et Résultats
Assurez-vous d'avoir la structure de projet suivante :
demo/
├── client.go
├── go.mod
├── go.sum
├── greeter
│ └── greeter.pb.go
├── server.go
└── greeter.proto
Pour observer le comportement des délais d'attente, lancez d'abord le serveur Go :
go run server.go
Le serveur affichera :
2023/10/27 10:00:00 Serveur gRPC écoutant sur :8000...
Ensuite, lancez le client Go :
go run client.go
Cas 1 : Délai du client inférieur au temps de traitement du serveur (client timeout: 2s, server procsesing: 3s)
Sortie du client :
2023/10/27 10:00:05 Tentative d'appel SayHello avec un délai de 2s...
2023/10/27 10:00:07 L'appel SayHello a expiré après 2s !
2023/10/27 10:00:07 Tentative d'appel SayHello avec un délai de 5s...
...
Sortie du serveur (correspondant au premier appel) :
2023/10/27 10:00:05 Réception d'une requête SayHello de 'Utilisateur gRPC'.
2023/10/27 10:00:07 Contexte annulé pour la requête de 'Utilisateur gRPC': context deadline exceeded
2023/10/27 10:00:07 L'opération pour 'Utilisateur gRPC' a été annulée pendant son exécution.
...
Le client annule l'appel après 2 secondes, et le serveur détecte l'annulation du contexte, interrompant son opération en cours.
Cas 2 : Délai du client supérieur au temps de traitement du serveur (client timeout: 5s, server processing: 3s)
Sortie du client (correspondant au deuxième appel) :
...
2023/10/27 10:00:07 Tentative d'appel SayHello avec un délai de 5s...
2023/10/27 10:00:10 Réponse du serveur reçue (délai 5s) : 'Bonjour, Utilisateur gRPC depuis le serveur !' (Statut: 200)
Sortie du serveur (correspondant au deuxième appel) :
...
2023/10/27 10:00:07 Réception d'une requête SayHello de 'Utilisateur gRPC'.
2023/10/27 10:00:10 Opération pour 'Utilisateur gRPC' terminée après 3s.
2023/10/27 10:00:10 Opération terminée pour 'Utilisateur gRPC'. Envoi de la réponse.
Dans ce scénario, le serveur termine son traitement dans le délai imparti par le client, et la réponse est renvoyée avec succès.