03 - Le client gRPC en TypeScript
Ce que tu vas apprendre
- Creer un client gRPC à partir du meme fichier .proto
- Faire des appels unaires avec gestion d'erreurs
- Gerer les deadlines, timeouts et retries
- Réutiliser les connexions efficacement
Prerequisites
02 - Creer un serveur gRPC en TypeScript
Le client de base
On va créer un client qui appelle le serveur de taches construit dans l'article précédent. Le meme fichier task.proto généré le code client et serveur -- c'est un des gros avantages de Protobuf.
typescript// src/client.ts
import * as grpc from "@grpc/grpc-js";
import { TaskServiceClient } from "./generated/task";
// Creer le client
const client = new TaskServiceClient(
"localhost:50051",
grpc.credentials.createInsecure()
);
// Appel unaire : creer une tache
function createTask(title: string, description: string): Promise<Task> {
return new Promise((resolve, reject) => {
client.createTask({ title, description }, (err, response) => {
if (err) return reject(err);
resolve(response!);
});
});
}
// Appel unaire : recuperer une tache
function getTask(taskId: string): Promise<Task> {
return new Promise((resolve, reject) => {
client.getTask({ taskId }, (err, response) => {
if (err) return reject(err);
resolve(response!);
});
});
}
// Utilisation
async function main() {
const task = await createTask("Deployer le service", "Mettre en prod avant vendredi");
console.log("Tache creee:", task.id);
const fetched = await getTask(task.id);
console.log("Tache recuperee:", fetched.title, "- status:", fetched.status);
}
main().catch(console.error);
Le pattern est simple : le client généré par ts-proto expose les memes méthodes que le service, mais cote appelant. Chaque méthode prend un message de requête et un callback.
Promisifier les appels
Les callbacks, c'est penible. Voici un wrapper générique pour transformer les appels gRPC en promesses :
typescript// src/utils/promisify-grpc.ts
import * as grpc from "@grpc/grpc-js";
type GrpcCallback<T> = (err: grpc.ServiceError | null, response: T) => void;
export function grpcPromise<Req, Res>(
method: (request: Req, callback: GrpcCallback<Res>) => void,
request: Req
): Promise<Res> {
return new Promise((resolve, reject) => {
method(request, (err, response) => {
if (err) return reject(err);
resolve(response);
});
});
}
// Utilisation
const task = await grpcPromise(
client.createTask.bind(client),
{ title: "Ma tache", description: "" }
);
Certains generateurs de code (comme ts-proto avec l'option outputServices=nice-grpc) generent directement des clients async/await. Si tu peux choisir, c'est l'approche la plus propre.
Gestion d'erreurs
Les erreurs gRPC arrivent sous forme de grpc.ServiceError avec un code et un message. Tu dois gerer chaque code selon le contexte.
typescriptimport * as grpc from "@grpc/grpc-js";
async function getTaskSafe(taskId: string) {
try {
return await grpcPromise(client.getTask.bind(client), { taskId });
} catch (err) {
const error = err as grpc.ServiceError;
switch (error.code) {
case grpc.status.NOT_FOUND:
console.log(`Tache ${taskId} introuvable`);
return null;
case grpc.status.UNAVAILABLE:
console.error("Service indisponible, retry dans 1s...");
await sleep(1000);
return getTaskSafe(taskId); // Retry (simplifie)
case grpc.status.INVALID_ARGUMENT:
throw new Error(`Requete invalide: ${error.message}`);
default:
console.error(`Erreur inattendue (code ${error.code}): ${error.message}`);
throw error;
}
}
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
Quelques regles de base :
NOT_FOUND: c'est normal, gere-le comme un cas métierUNAVAILABLE: le service est down, tu peux retenterINVALID_ARGUMENT: c'est un bug dans ton code client, corrige-leINTERNAL: c'est un bug dans le serveur, log et remonte l'erreurDEADLINE_EXCEEDED: le serveur a mis trop de temps, voir la section suivante
Deadlines et timeouts
En microservices, un appel sans timeout est une bombe a retardement. Si le serveur ne répond jamais, ton client attend indefiniment. En gRPC, ca se gere avec les deadlines.
typescript// Timeout de 5 secondes
const deadline = new Date();
deadline.setSeconds(deadline.getSeconds() + 5);
client.getTask(
{ taskId: "abc-123" },
{ deadline },
(err, response) => {
if (err?.code === grpc.status.DEADLINE_EXCEEDED) {
console.error("Le serveur n'a pas repondu dans les 5 secondes");
return;
}
// ...
}
);
Ou avec le wrapper promisifie :
typescriptexport function grpcPromiseWithTimeout<Req, Res>(
method: (request: Req, options: Partial<grpc.CallOptions>, callback: GrpcCallback<Res>) => void,
request: Req,
timeoutMs: number
): Promise<Res> {
const deadline = new Date(Date.now() + timeoutMs);
return new Promise((resolve, reject) => {
method(request, { deadline }, (err, response) => {
if (err) return reject(err);
resolve(response);
});
});
}
// 3 secondes de timeout
const task = await grpcPromiseWithTimeout(
client.getTask.bind(client),
{ taskId: "abc-123" },
3000
);
Regle : mets toujours un timeout. Toujours. Meme en dev. Un appel gRPC sans deadline dans une architecture microservices est un des vecteurs de cascade failures les plus courants.
Retry avec backoff
Un retry naif (retenter immédiatement en boucle) peut aggraver une surcharge du serveur. L'exponential backoff attend de plus en plus longtemps entre chaque tentative.
typescriptasync function withRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
baseDelayMs: number = 500
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
const error = err as grpc.ServiceError;
lastError = error;
// Ne retente que pour les erreurs transitoires
const retryable = [
grpc.status.UNAVAILABLE,
grpc.status.DEADLINE_EXCEEDED,
grpc.status.RESOURCE_EXHAUSTED
];
if (!retryable.includes(error.code) || attempt === maxRetries) {
throw error;
}
const delay = baseDelayMs * Math.pow(2, attempt);
const jitter = Math.random() * delay * 0.1;
await sleep(delay + jitter);
}
}
throw lastError;
}
// Utilisation
const task = await withRetry(() =>
grpcPromise(client.getTask.bind(client), { taskId: "abc-123" })
);
Le jitter (variation aleatoire) évité que 100 clients retentent en meme temps apres une panne, ce qui creerait un "thundering herd".
Gestion des connexions
Un client gRPC maintient une connexion HTTP/2 persistante. Ne créé pas un nouveau client a chaque appel -- c'est un gaspillage énorme de ressources.
typescript// MAUVAIS : nouveau client a chaque appel
async function getTask(taskId: string) {
const client = new TaskServiceClient("localhost:50051", grpc.credentials.createInsecure());
// ... appel
client.close(); // gaspillage
}
// BON : un client reutilise
const client = new TaskServiceClient("localhost:50051", grpc.credentials.createInsecure());
async function getTask(taskId: string) {
return grpcPromise(client.getTask.bind(client), { taskId });
}
Surveille l'état de la connexion :
typescriptclient.waitForReady(Date.now() + 5000, (err) => {
if (err) {
console.error("Connexion au serveur echouee");
process.exit(1);
}
console.log("Connecte au serveur gRPC");
});
Sur paltemps.fr, les appels entre modules sont des fonctions locales -- pas de connexion réseau a gerer. C'est un des avantages concrets du monolithe. En microservices, la gestion des connexions gRPC est un détail important qu'on ne peut pas ignorer.
Résumé
- Le client gRPC est généré depuis le meme fichier .proto que le serveur
- Promisifie les callbacks pour un code plus lisible
- Gere chaque status code selon sa semantique (NOT_FOUND ≠ INTERNAL)
- Mets toujours un deadline sur les appels -- jamais d'appel sans timeout
- Utilise l'exponential backoff avec jitter pour les retries
- Reutilise les connexions, ne créé pas un client par appel
Article précédent : 02 - Creer un serveur gRPC Article suivant : 04 - Le streaming : la vraie force de gRPC