Skip to content

Commit 96e6e71

Browse files
committed
test: add gRPC retry policy coverage
Add retry-policy tests for transient UNAVAILABLE, retry exhaustion, and non-retryable DEADLINE_EXCEEDED. Extend the test gRPC server with fault injection and server-side attempt tracking. Verify retry headers on successful retries and exact attempt counts on exhausted failures.
1 parent 08c18b6 commit 96e6e71

7 files changed

Lines changed: 425 additions & 39 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Flags: --expose-internals
2+
3+
import { mustCall, mustSucceed } from '../common/index.mjs';
4+
import { GRPCServer, TestClient } from '../common/nsolid-grpc-agent/index.js';
5+
6+
import assert from 'node:assert';
7+
8+
const services = [
9+
{ name: 'ExportInfo', trigger: (client, s, id) => s.info(id), event: 'info' },
10+
{ name: 'ExportMetricsCmd', trigger: (client, s, id) => s.metrics(id), event: 'metrics_cmd' },
11+
{ name: 'ExportSpans', trigger: (client, s, id) => client.trace('http'), event: 'spans' },
12+
];
13+
14+
const tests = [];
15+
16+
tests.push({
17+
name: 'should retry on transient UNAVAILABLE and succeed',
18+
test: async (getEnv) => {
19+
return new Promise((resolve) => {
20+
const grpcServer = new GRPCServer();
21+
grpcServer.start(mustSucceed(async (port) => {
22+
const env = getEnv(port);
23+
const client = new TestClient([], { env });
24+
const agentId = await client.id();
25+
26+
let completed = 0;
27+
const total = services.length;
28+
29+
for (const svc of services) {
30+
// Inject failure for the first call
31+
grpcServer.injectFailure(svc.name, 'UNAVAILABLE', 1);
32+
33+
if (svc.name === 'ExportSpans') {
34+
grpcServer.on(svc.event, mustCall(async (data) => {
35+
const attemptsHeader = data.metadata['grpc-previous-rpc-attempts'];
36+
assert(attemptsHeader && attemptsHeader[0] === '1', `Should have retried once for ${svc.name}, got ${attemptsHeader}`);
37+
completed++;
38+
if (completed === total) {
39+
grpcServer.clearFaults();
40+
await client.shutdown(0);
41+
grpcServer.close();
42+
resolve();
43+
}
44+
}));
45+
await client.trace('http');
46+
continue;
47+
}
48+
49+
svc.trigger(client, grpcServer, agentId).then(async ({ data }) => {
50+
const attemptsHeader = data.metadata['grpc-previous-rpc-attempts'];
51+
assert(attemptsHeader && attemptsHeader[0] === '1', `Should have retried once for ${svc.name}, got ${attemptsHeader}`);
52+
completed++;
53+
if (completed === total) {
54+
grpcServer.clearFaults();
55+
await client.shutdown(0);
56+
grpcServer.close();
57+
resolve();
58+
}
59+
}).then(mustCall());
60+
}
61+
}));
62+
});
63+
},
64+
});
65+
66+
tests.push({
67+
name: 'should fail after max retries (5) on persistent UNAVAILABLE',
68+
test: async (getEnv) => {
69+
return new Promise((resolve) => {
70+
const grpcServer = new GRPCServer();
71+
grpcServer.start(mustSucceed(async (port) => {
72+
const env = getEnv(port);
73+
const client = new TestClient([], { env });
74+
const agentId = await client.id();
75+
76+
let completed = 0;
77+
const total = services.length;
78+
79+
for (const svc of services) {
80+
// Inject failures for more than 5 attempts
81+
grpcServer.injectFailure(svc.name, 'UNAVAILABLE', 20);
82+
83+
if (svc.name === 'ExportSpans') {
84+
const timeout = new Promise((resolveTimeout) => setTimeout(() => resolveTimeout('timeout'), 10000));
85+
const success = new Promise((resolveSuccess) => grpcServer.on(svc.event, () => resolveSuccess('success')));
86+
Promise.race([success, timeout]).then(async (result) => {
87+
if (result === 'timeout') {
88+
const stats = await grpcServer.attempts(svc.name);
89+
assert.strictEqual(stats.total, 5, `Should attempt ${svc.name} 5 times`);
90+
assert.strictEqual(stats.lastPreviousRpcAttempts, 4,
91+
`Last retry header for ${svc.name} should be 4`);
92+
completed++;
93+
if (completed === total) {
94+
grpcServer.clearFaults();
95+
await client.shutdown(0);
96+
grpcServer.close();
97+
resolve();
98+
}
99+
} else {
100+
throw new Error(`Should have failed after retries for ${svc.name}`);
101+
}
102+
}).then(mustCall());
103+
}
104+
105+
const triggerPromise = svc.trigger(client, grpcServer, agentId);
106+
if (svc.name !== 'ExportSpans') {
107+
const timeout = new Promise((resolveTimeout) => setTimeout(() => resolveTimeout('timeout'), 20000));
108+
Promise.race([triggerPromise, timeout]).then(async (result) => {
109+
if (result === 'timeout') {
110+
const stats = await grpcServer.attempts(svc.name);
111+
assert.strictEqual(stats.total, 5, `Should attempt ${svc.name} 5 times`);
112+
assert.strictEqual(stats.lastPreviousRpcAttempts, 4,
113+
`Last retry header for ${svc.name} should be 4`);
114+
completed++;
115+
if (completed === total) {
116+
grpcServer.clearFaults();
117+
await client.shutdown(0);
118+
grpcServer.close();
119+
resolve();
120+
}
121+
} else {
122+
throw new Error(`Should have failed after retries for ${svc.name}`);
123+
}
124+
}).then(mustCall());
125+
}
126+
}
127+
}));
128+
});
129+
},
130+
});
131+
132+
tests.push({
133+
name: 'should not retry on non-retryable DEADLINE_EXCEEDED',
134+
test: async (getEnv) => {
135+
return new Promise((resolve) => {
136+
const grpcServer = new GRPCServer();
137+
grpcServer.start(mustSucceed(async (port) => {
138+
const env = getEnv(port);
139+
const client = new TestClient([], { env });
140+
const agentId = await client.id();
141+
142+
let completed = 0;
143+
const total = services.length;
144+
145+
for (const svc of services) {
146+
grpcServer.injectFailure(svc.name, 'DEADLINE_EXCEEDED', 1);
147+
148+
if (svc.name === 'ExportSpans') {
149+
const timeout = new Promise((resolveTimeout) => setTimeout(() => resolveTimeout('timeout'), 1000));
150+
const success = new Promise((resolve) => grpcServer.on(svc.event, () => resolve('success')));
151+
Promise.race([success, timeout]).then(async (result) => {
152+
if (result === 'timeout') {
153+
const stats = await grpcServer.attempts(svc.name);
154+
assert.strictEqual(stats.total, 1, `Should attempt ${svc.name} only once`);
155+
assert.strictEqual(stats.lastPreviousRpcAttempts, 0,
156+
`Retry header for ${svc.name} should remain unset`);
157+
completed++;
158+
if (completed === total) {
159+
await client.shutdown(0);
160+
grpcServer.clearFaults();
161+
grpcServer.close();
162+
resolve();
163+
}
164+
} else {
165+
await client.shutdown(0);
166+
throw new Error(`Should have failed immediately for ${svc.name}`);
167+
}
168+
}).then(mustCall());
169+
}
170+
171+
const triggerPromise = svc.trigger(client, grpcServer, agentId);
172+
if (svc.name !== 'ExportSpans') {
173+
const timeout = new Promise((resolveTimeout) => setTimeout(() => resolveTimeout('timeout'), 1000));
174+
Promise.race([triggerPromise, timeout]).then(async (result) => {
175+
if (result === 'timeout') {
176+
const stats = await grpcServer.attempts(svc.name);
177+
assert.strictEqual(stats.total, 1, `Should attempt ${svc.name} only once`);
178+
assert.strictEqual(stats.lastPreviousRpcAttempts, 0,
179+
`Retry header for ${svc.name} should remain unset`);
180+
completed++;
181+
if (completed === total) {
182+
await client.shutdown(0);
183+
grpcServer.clearFaults();
184+
grpcServer.close();
185+
resolve();
186+
}
187+
} else {
188+
await client.shutdown(0);
189+
throw new Error(`Should have failed immediately for ${svc.name}`);
190+
}
191+
}).then(mustCall());
192+
}
193+
}
194+
}));
195+
});
196+
},
197+
});
198+
199+
const testConfigs = [
200+
{
201+
getEnv: (port) => ({
202+
NODE_DEBUG_NATIVE: 'nsolid_grpc_agent',
203+
NSOLID_GRPC: `localhost:${port}`,
204+
NSOLID_GRPC_INSECURE: 1,
205+
NSOLID_TRACING_ENABLED: 1,
206+
}),
207+
},
208+
];
209+
210+
for (const testConfig of testConfigs) {
211+
for (const { name, test } of tests) {
212+
console.log(`[retry-policies] ${name}`);
213+
await test(testConfig.getEnv);
214+
}
215+
}

test/agents/test-grpc-tracing.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ tests.push({
205205
if (phase === 'done')
206206
return;
207207

208-
mergeResourceSpans(spans, resourceSpans);
208+
mergeResourceSpans(spans.request, resourceSpans);
209209

210210
if (phase === 'initial' &&
211211
resourceSpans.length === 1 &&
@@ -274,7 +274,7 @@ tests.push({
274274
if (phase === 'done')
275275
return;
276276

277-
mergeResourceSpans(spans, resourceSpans);
277+
mergeResourceSpans(spans.request, resourceSpans);
278278

279279
if (phase === 'initial' &&
280280
resourceSpans.length === 1 &&

test/common/nsolid-grpc-agent/index.js

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,13 @@ class GRPCServer extends EventEmitter {
216216
if (this.#server) {
217217
const requestId = randomUUID();
218218
this.#server.send({ type: 'info', agentId, requestId });
219-
this.#server.once('message', (msg) => {
220-
if (msg.type === 'info') {
219+
const msgListener = (msg) => {
220+
if (msg.type === 'info' && msg.data.msg.common.requestId === requestId) {
221+
this.#server.off('message', msgListener);
221222
resolve({ requestId, data: msg.data });
222223
}
223-
});
224+
};
225+
this.#server.on('message', msgListener);
224226
} else {
225227
resolve(null);
226228
}
@@ -232,11 +234,13 @@ class GRPCServer extends EventEmitter {
232234
if (this.#server) {
233235
const requestId = randomUUID();
234236
this.#server.send({ type: 'metrics', agentId, requestId });
235-
this.#server.on('message', (msg) => {
236-
if (msg.type === 'metrics_cmd') {
237+
const msgListener = (msg) => {
238+
if (msg.type === 'metrics_cmd' && msg.data.msg.common.requestId === requestId) {
239+
this.#server.off('message', msgListener);
237240
resolve({ requestId, data: msg.data });
238241
}
239-
});
242+
};
243+
this.#server.on('message', msgListener);
240244
} else {
241245
resolve(null);
242246
}
@@ -312,6 +316,42 @@ class GRPCServer extends EventEmitter {
312316
});
313317
}
314318

319+
injectFailure(service, status = 'UNAVAILABLE', count = 1) {
320+
if (this.#server) {
321+
this.#server.send({ type: 'inject_failure', service, status, count });
322+
}
323+
}
324+
325+
injectDelay(service, delayMs = 0) {
326+
if (this.#server) {
327+
this.#server.send({ type: 'inject_delay', service, delay: delayMs });
328+
}
329+
}
330+
331+
async attempts(service) {
332+
return new Promise((resolve) => {
333+
if (this.#server) {
334+
const messageHandler = (msg) => {
335+
if (msg.type === 'attempts' && msg.data.service === service) {
336+
this.#server.removeListener('message', messageHandler);
337+
resolve(msg.data);
338+
}
339+
};
340+
341+
this.#server.on('message', messageHandler);
342+
this.#server.send({ type: 'get_attempts', service });
343+
} else {
344+
resolve({ service, total: 0, lastPreviousRpcAttempts: 0 });
345+
}
346+
});
347+
}
348+
349+
clearFaults() {
350+
if (this.#server) {
351+
this.#server.send({ type: 'clear_faults' });
352+
}
353+
}
354+
315355
close() {
316356
this.#server.send({ type: 'close' });
317357
}

0 commit comments

Comments
 (0)