Skip to content

Commit 333e1c1

Browse files
authored
[copilot-finds] Bug: Fix off-by-one in createServiceConfig maxRetries→maxAttempts conversion (#287)
1 parent 1a36fc8 commit 333e1c1

2 files changed

Lines changed: 188 additions & 1 deletion

File tree

packages/durabletask-js-azuremanaged/src/retry-policy.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ function statusCodeToName(code: GrpcStatus): string {
8888
* @returns A JSON string representing the service config.
8989
*/
9090
export function createServiceConfig(options?: ClientRetryOptions): string {
91-
const maxAttempts = options?.maxRetries ?? DEFAULT_MAX_ATTEMPTS;
91+
// gRPC maxAttempts includes the original attempt, while maxRetries means
92+
// retries *after* the initial call. Convert by adding 1.
93+
const maxAttempts = options?.maxRetries != null ? options.maxRetries + 1 : DEFAULT_MAX_ATTEMPTS;
9294
const initialBackoffMs = options?.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS;
9395
const maxBackoffMs = options?.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
9496
const backoffMultiplier = options?.backoffMultiplier ?? DEFAULT_BACKOFF_MULTIPLIER;
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { status as GrpcStatus } from "@grpc/grpc-js";
5+
import {
6+
createServiceConfig,
7+
DEFAULT_SERVICE_CONFIG,
8+
DEFAULT_MAX_ATTEMPTS,
9+
DEFAULT_INITIAL_BACKOFF_MS,
10+
DEFAULT_MAX_BACKOFF_MS,
11+
DEFAULT_BACKOFF_MULTIPLIER,
12+
ClientRetryOptions,
13+
} from "../../src/retry-policy";
14+
15+
describe("retry-policy", () => {
16+
describe("DEFAULT_SERVICE_CONFIG", () => {
17+
it("should be valid JSON", () => {
18+
expect(() => JSON.parse(DEFAULT_SERVICE_CONFIG)).not.toThrow();
19+
});
20+
21+
it("should contain a retry policy", () => {
22+
const config = JSON.parse(DEFAULT_SERVICE_CONFIG);
23+
const retryPolicy = config.methodConfig[0].retryPolicy;
24+
expect(retryPolicy).toBeDefined();
25+
});
26+
27+
it("should use default values", () => {
28+
const config = JSON.parse(DEFAULT_SERVICE_CONFIG);
29+
const retryPolicy = config.methodConfig[0].retryPolicy;
30+
31+
expect(retryPolicy.maxAttempts).toBe(DEFAULT_MAX_ATTEMPTS);
32+
expect(retryPolicy.initialBackoff).toBe(`${DEFAULT_INITIAL_BACKOFF_MS / 1000}s`);
33+
expect(retryPolicy.maxBackoff).toBe(`${DEFAULT_MAX_BACKOFF_MS / 1000}s`);
34+
expect(retryPolicy.backoffMultiplier).toBe(DEFAULT_BACKOFF_MULTIPLIER);
35+
});
36+
37+
it("should include UNAVAILABLE status code by default", () => {
38+
const config = JSON.parse(DEFAULT_SERVICE_CONFIG);
39+
const retryPolicy = config.methodConfig[0].retryPolicy;
40+
expect(retryPolicy.retryableStatusCodes).toContain("UNAVAILABLE");
41+
});
42+
43+
it("should apply to all gRPC methods via empty service name", () => {
44+
const config = JSON.parse(DEFAULT_SERVICE_CONFIG);
45+
expect(config.methodConfig[0].name).toEqual([{ service: "" }]);
46+
});
47+
});
48+
49+
describe("createServiceConfig", () => {
50+
it("should return the same config as DEFAULT_SERVICE_CONFIG when called without options", () => {
51+
const config = createServiceConfig();
52+
expect(config).toBe(DEFAULT_SERVICE_CONFIG);
53+
});
54+
55+
it("should return the same config as DEFAULT_SERVICE_CONFIG when called with undefined", () => {
56+
const config = createServiceConfig(undefined);
57+
expect(JSON.parse(config)).toEqual(JSON.parse(DEFAULT_SERVICE_CONFIG));
58+
});
59+
60+
describe("maxRetries to maxAttempts conversion", () => {
61+
it("should convert maxRetries to maxAttempts by adding 1 (retries + initial attempt)", () => {
62+
const config = JSON.parse(createServiceConfig({ maxRetries: 3 }));
63+
// maxRetries: 3 means 3 retries + 1 initial = 4 total attempts
64+
expect(config.methodConfig[0].retryPolicy.maxAttempts).toBe(4);
65+
});
66+
67+
it("should produce maxAttempts: 1 when maxRetries is 0 (no retries, just the initial call)", () => {
68+
const config = JSON.parse(createServiceConfig({ maxRetries: 0 }));
69+
// maxRetries: 0 means 0 retries + 1 initial = 1 total attempt
70+
expect(config.methodConfig[0].retryPolicy.maxAttempts).toBe(1);
71+
});
72+
73+
it("should produce maxAttempts: 2 when maxRetries is 1", () => {
74+
const config = JSON.parse(createServiceConfig({ maxRetries: 1 }));
75+
expect(config.methodConfig[0].retryPolicy.maxAttempts).toBe(2);
76+
});
77+
78+
it("should use DEFAULT_MAX_ATTEMPTS when maxRetries is not specified", () => {
79+
const config = JSON.parse(createServiceConfig({}));
80+
expect(config.methodConfig[0].retryPolicy.maxAttempts).toBe(DEFAULT_MAX_ATTEMPTS);
81+
});
82+
});
83+
84+
describe("backoff configuration", () => {
85+
it("should convert initialBackoffMs to seconds string", () => {
86+
const config = JSON.parse(createServiceConfig({ initialBackoffMs: 100 }));
87+
expect(config.methodConfig[0].retryPolicy.initialBackoff).toBe("0.1s");
88+
});
89+
90+
it("should convert maxBackoffMs to seconds string", () => {
91+
const config = JSON.parse(createServiceConfig({ maxBackoffMs: 5000 }));
92+
expect(config.methodConfig[0].retryPolicy.maxBackoff).toBe("5s");
93+
});
94+
95+
it("should apply the specified backoff multiplier", () => {
96+
const config = JSON.parse(createServiceConfig({ backoffMultiplier: 3 }));
97+
expect(config.methodConfig[0].retryPolicy.backoffMultiplier).toBe(3);
98+
});
99+
});
100+
101+
describe("retryable status codes", () => {
102+
it("should always include UNAVAILABLE even when custom codes are specified", () => {
103+
const config = JSON.parse(
104+
createServiceConfig({
105+
retryableStatusCodes: [GrpcStatus.INTERNAL],
106+
}),
107+
);
108+
const statusCodes = config.methodConfig[0].retryPolicy.retryableStatusCodes;
109+
expect(statusCodes).toContain("UNAVAILABLE");
110+
expect(statusCodes).toContain("INTERNAL");
111+
});
112+
113+
it("should not duplicate UNAVAILABLE when already in custom codes", () => {
114+
const config = JSON.parse(
115+
createServiceConfig({
116+
retryableStatusCodes: [GrpcStatus.UNAVAILABLE, GrpcStatus.INTERNAL],
117+
}),
118+
);
119+
const statusCodes: string[] = config.methodConfig[0].retryPolicy.retryableStatusCodes;
120+
const unavailableCount = statusCodes.filter((c) => c === "UNAVAILABLE").length;
121+
expect(unavailableCount).toBe(1);
122+
});
123+
124+
it("should map all standard gRPC status codes correctly", () => {
125+
const allCodes = [
126+
{ code: GrpcStatus.OK, name: "OK" },
127+
{ code: GrpcStatus.CANCELLED, name: "CANCELLED" },
128+
{ code: GrpcStatus.UNKNOWN, name: "UNKNOWN" },
129+
{ code: GrpcStatus.INVALID_ARGUMENT, name: "INVALID_ARGUMENT" },
130+
{ code: GrpcStatus.DEADLINE_EXCEEDED, name: "DEADLINE_EXCEEDED" },
131+
{ code: GrpcStatus.NOT_FOUND, name: "NOT_FOUND" },
132+
{ code: GrpcStatus.ALREADY_EXISTS, name: "ALREADY_EXISTS" },
133+
{ code: GrpcStatus.PERMISSION_DENIED, name: "PERMISSION_DENIED" },
134+
{ code: GrpcStatus.RESOURCE_EXHAUSTED, name: "RESOURCE_EXHAUSTED" },
135+
{ code: GrpcStatus.FAILED_PRECONDITION, name: "FAILED_PRECONDITION" },
136+
{ code: GrpcStatus.ABORTED, name: "ABORTED" },
137+
{ code: GrpcStatus.OUT_OF_RANGE, name: "OUT_OF_RANGE" },
138+
{ code: GrpcStatus.UNIMPLEMENTED, name: "UNIMPLEMENTED" },
139+
{ code: GrpcStatus.INTERNAL, name: "INTERNAL" },
140+
{ code: GrpcStatus.UNAVAILABLE, name: "UNAVAILABLE" },
141+
{ code: GrpcStatus.DATA_LOSS, name: "DATA_LOSS" },
142+
{ code: GrpcStatus.UNAUTHENTICATED, name: "UNAUTHENTICATED" },
143+
];
144+
145+
for (const { code, name } of allCodes) {
146+
const config = JSON.parse(
147+
createServiceConfig({ retryableStatusCodes: [code] }),
148+
);
149+
const statusCodes: string[] = config.methodConfig[0].retryPolicy.retryableStatusCodes;
150+
expect(statusCodes).toContain(name);
151+
}
152+
});
153+
154+
it("should fall back to 'UNKNOWN' for unrecognized status codes", () => {
155+
const config = JSON.parse(
156+
createServiceConfig({ retryableStatusCodes: [999 as GrpcStatus] }),
157+
);
158+
const statusCodes: string[] = config.methodConfig[0].retryPolicy.retryableStatusCodes;
159+
expect(statusCodes).toContain("UNKNOWN");
160+
});
161+
});
162+
163+
describe("full custom configuration", () => {
164+
it("should apply all custom options together", () => {
165+
const options: ClientRetryOptions = {
166+
maxRetries: 5,
167+
initialBackoffMs: 200,
168+
maxBackoffMs: 2000,
169+
backoffMultiplier: 1.5,
170+
retryableStatusCodes: [GrpcStatus.UNAVAILABLE, GrpcStatus.DEADLINE_EXCEEDED],
171+
};
172+
173+
const config = JSON.parse(createServiceConfig(options));
174+
const retryPolicy = config.methodConfig[0].retryPolicy;
175+
176+
expect(retryPolicy.maxAttempts).toBe(6); // 5 retries + 1 initial
177+
expect(retryPolicy.initialBackoff).toBe("0.2s");
178+
expect(retryPolicy.maxBackoff).toBe("2s");
179+
expect(retryPolicy.backoffMultiplier).toBe(1.5);
180+
expect(retryPolicy.retryableStatusCodes).toContain("UNAVAILABLE");
181+
expect(retryPolicy.retryableStatusCodes).toContain("DEADLINE_EXCEEDED");
182+
});
183+
});
184+
});
185+
});

0 commit comments

Comments
 (0)