Skip to content

Commit 49bf850

Browse files
committed
Improve the retry.fetch default behavior and option structure
1 parent 4d3c32c commit 49bf850

File tree

7 files changed

+501
-159
lines changed

7 files changed

+501
-159
lines changed

packages/core/src/v3/schemas/fetch.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from "zod";
22
import { RetryOptions } from "./messages";
33
import { EventFilter } from "./eventFilter";
4+
import { Prettify } from "../types";
45

56
export const FetchRetryHeadersStrategy = z.object({
67
/** The `headers` strategy retries the request using info from the response headers. */
@@ -45,14 +46,14 @@ export const FetchRetryStrategy = z.discriminatedUnion("strategy", [
4546

4647
export type FetchRetryStrategy = z.infer<typeof FetchRetryStrategy>;
4748

48-
export const FetchRetryOptions = z.record(FetchRetryStrategy);
49+
export const FetchRetryByStatusOptions = z.record(z.string(), FetchRetryStrategy);
4950

5051
/** An object where the key is a status code pattern and the value is a retrying strategy. Supported patterns are:
5152
- Specific status codes: 429
5253
- Ranges: 500-599
5354
- Wildcards: 2xx, 3xx, 4xx, 5xx
5455
*/
55-
export type FetchRetryOptions = z.infer<typeof FetchRetryOptions>;
56+
export type FetchRetryByStatusOptions = Prettify<z.infer<typeof FetchRetryByStatusOptions>>;
5657

5758
export const FetchTimeoutOptions = z.object({
5859
/** The maximum time to wait for the request to complete. */
@@ -61,3 +62,16 @@ export const FetchTimeoutOptions = z.object({
6162
});
6263

6364
export type FetchTimeoutOptions = z.infer<typeof FetchTimeoutOptions>;
65+
66+
export const FetchRetryOptions = z.object({
67+
/** The retrying strategy for specific status codes. */
68+
byStatus: FetchRetryByStatusOptions.optional(),
69+
/** The timeout options for the request. */
70+
timeout: RetryOptions.optional(),
71+
/**
72+
* The retrying strategy for connection errors.
73+
*/
74+
connectionError: RetryOptions.optional(),
75+
});
76+
77+
export type FetchRetryOptions = Prettify<z.infer<typeof FetchRetryOptions>>;

packages/core/src/v3/utils/flattenAttributes.ts

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ export function flattenAttributes(
3232
for (let i = 0; i < value.length; i++) {
3333
if (typeof value[i] === "object" && value[i] !== null) {
3434
// update null check here as well
35-
Object.assign(result, flattenAttributes(value[i], `${newPrefix}.${i}`));
35+
Object.assign(result, flattenAttributes(value[i], `${newPrefix}.[${i}]`));
3636
} else {
37-
result[`${newPrefix}.${i}`] = value[i];
37+
result[`${newPrefix}.[${i}]`] = value[i];
3838
}
3939
}
4040
} else if (isRecord(value)) {
@@ -55,46 +55,54 @@ function isRecord(value: unknown): value is Record<string, unknown> {
5555
}
5656

5757
export function unflattenAttributes(obj: Attributes): Record<string, unknown> {
58-
if (
59-
obj === null ||
60-
obj === undefined ||
61-
typeof obj === "string" ||
62-
typeof obj === "number" ||
63-
typeof obj === "boolean" ||
64-
Array.isArray(obj)
65-
) {
58+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
6659
return obj;
6760
}
6861

6962
const result: Record<string, unknown> = {};
7063

7164
for (const [key, value] of Object.entries(obj)) {
72-
const parts = key.split(".");
73-
let current = result;
65+
const parts = key.split(".").reduce((acc, part) => {
66+
// Splitting array indices as separate parts
67+
if (detectIsArrayIndex(part)) {
68+
acc.push(part);
69+
} else {
70+
acc.push(...part.split(/\.\[(.*?)\]/).filter(Boolean));
71+
}
72+
return acc;
73+
}, [] as string[]);
74+
75+
let current: Record<string, unknown> = result;
7476
for (let i = 0; i < parts.length - 1; i++) {
7577
const part = parts[i];
76-
77-
// Check if part is not undefined and it's a string.
78-
if (typeof part === "string") {
79-
const nextPart = parts[i + 1];
80-
const isArray = nextPart ? parseInt(nextPart, 10).toString() === nextPart : false;
81-
if (current[part] == null) {
82-
current[part] = isArray ? [] : {};
83-
}
84-
85-
current = current[part] as Record<string, unknown>;
78+
const isArray = detectIsArrayIndex(part);
79+
const cleanPart = isArray ? part.substring(1, part.length - 1) : part;
80+
const nextIsArray = detectIsArrayIndex(parts[i + 1]);
81+
if (!current[cleanPart]) {
82+
current[cleanPart] = nextIsArray ? [] : {};
8683
}
84+
current = current[cleanPart] as Record<string, unknown>;
8785
}
88-
// For the last element, we must ensure we also check if it is not undefined and it's a string.
8986
const lastPart = parts[parts.length - 1];
90-
if (typeof lastPart === "string") {
91-
current[lastPart] = value;
92-
}
87+
const cleanLastPart = detectIsArrayIndex(lastPart)
88+
? parseInt(lastPart.substring(1, lastPart.length - 1), 10)
89+
: lastPart;
90+
current[cleanLastPart] = value;
9391
}
9492

9593
return result;
9694
}
9795

96+
function detectIsArrayIndex(key: string): boolean {
97+
const match = key.match(/^\[(\d+)\]$/);
98+
99+
if (match) {
100+
return true;
101+
}
102+
103+
return false;
104+
}
105+
98106
export function primitiveValueOrflattenedAttributes(
99107
obj: Record<string, unknown> | Array<unknown> | string | boolean | number | undefined,
100108
prefix: string | undefined

packages/core/src/v3/utils/retries.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { type RetryOptions } from "../schemas";
21
import { calculateResetAt as calculateResetAtInternal } from "../../retry";
2+
import { FetchRetryOptions, type RetryOptions } from "../schemas";
33

44
export const defaultRetryOptions = {
55
maxAttempts: 3,
@@ -9,6 +9,17 @@ export const defaultRetryOptions = {
99
randomize: true,
1010
} satisfies RetryOptions;
1111

12+
export const defaultFetchRetryOptions = {
13+
byStatus: {
14+
"429,408,409,5xx": {
15+
strategy: "backoff",
16+
...defaultRetryOptions,
17+
},
18+
},
19+
connectionError: defaultRetryOptions,
20+
timeout: defaultRetryOptions,
21+
} satisfies FetchRetryOptions;
22+
1223
/**
1324
*
1425
* @param options
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { flattenAttributes, unflattenAttributes } from "../src/v3/utils/flattenAttributes";
2+
3+
describe("flattenAttributes", () => {
4+
it("handles null and undefined gracefully", () => {
5+
expect(flattenAttributes(null)).toEqual({});
6+
expect(flattenAttributes(undefined)).toEqual({});
7+
});
8+
9+
it("flattens string attributes correctly", () => {
10+
const result = flattenAttributes("testString");
11+
expect(result).toEqual({ "": "testString" });
12+
});
13+
14+
it("flattens number attributes correctly", () => {
15+
const result = flattenAttributes(12345);
16+
expect(result).toEqual({ "": 12345 });
17+
});
18+
19+
it("flattens boolean attributes correctly", () => {
20+
const result = flattenAttributes(true);
21+
expect(result).toEqual({ "": true });
22+
});
23+
24+
it("flattens complex objects correctly", () => {
25+
const obj = {
26+
level1: {
27+
level2: {
28+
value: "test",
29+
},
30+
array: [1, 2, 3],
31+
},
32+
};
33+
const expected = {
34+
"level1.level2.value": "test",
35+
"level1.array.[0]": 1,
36+
"level1.array.[1]": 2,
37+
"level1.array.[2]": 3,
38+
};
39+
expect(flattenAttributes(obj)).toEqual(expected);
40+
});
41+
42+
it("applies prefixes correctly", () => {
43+
const obj = { key: "value" };
44+
const expected = { "prefix.key": "value" };
45+
expect(flattenAttributes(obj, "prefix")).toEqual(expected);
46+
});
47+
48+
it("handles arrays of objects correctly", () => {
49+
const obj = {
50+
array: [{ key: "value" }, { key: "value" }, { key: "value" }],
51+
};
52+
const expected = {
53+
"array.[0].key": "value",
54+
"array.[1].key": "value",
55+
"array.[2].key": "value",
56+
};
57+
expect(flattenAttributes(obj)).toEqual(expected);
58+
});
59+
60+
it("handles arrays of objects correctly with prefixing correctly", () => {
61+
const obj = {
62+
array: [{ key: "value" }, { key: "value" }, { key: "value" }],
63+
};
64+
const expected = {
65+
"prefix.array.[0].key": "value",
66+
"prefix.array.[1].key": "value",
67+
"prefix.array.[2].key": "value",
68+
};
69+
expect(flattenAttributes(obj, "prefix")).toEqual(expected);
70+
});
71+
72+
it("handles objects of objects correctly", () => {
73+
const obj = {
74+
level1: {
75+
level2: {
76+
key: "value",
77+
},
78+
},
79+
};
80+
const expected = { "level1.level2.key": "value" };
81+
expect(flattenAttributes(obj)).toEqual(expected);
82+
});
83+
84+
it("handles objects of objects correctly with prefixing", () => {
85+
const obj = {
86+
level1: {
87+
level2: {
88+
key: "value",
89+
},
90+
},
91+
};
92+
const expected = { "prefix.level1.level2.key": "value" };
93+
expect(flattenAttributes(obj, "prefix")).toEqual(expected);
94+
});
95+
96+
it("handles retry.byStatus correctly", () => {
97+
const obj = {
98+
"500": {
99+
strategy: "backoff",
100+
maxAttempts: 2,
101+
factor: 2,
102+
minTimeoutInMs: 1_000,
103+
maxTimeoutInMs: 30_000,
104+
randomize: false,
105+
},
106+
};
107+
108+
const expected = {
109+
"retry.byStatus.500.strategy": "backoff",
110+
"retry.byStatus.500.maxAttempts": 2,
111+
"retry.byStatus.500.factor": 2,
112+
"retry.byStatus.500.minTimeoutInMs": 1_000,
113+
"retry.byStatus.500.maxTimeoutInMs": 30_000,
114+
"retry.byStatus.500.randomize": false,
115+
};
116+
117+
expect(flattenAttributes(obj, "retry.byStatus")).toEqual(expected);
118+
});
119+
});
120+
121+
describe("unflattenAttributes", () => {
122+
it("returns the original object for primitive types", () => {
123+
// @ts-expect-error
124+
expect(unflattenAttributes("testString")).toEqual("testString");
125+
// @ts-expect-error
126+
expect(unflattenAttributes(12345)).toEqual(12345);
127+
// @ts-expect-error
128+
expect(unflattenAttributes(true)).toEqual(true);
129+
});
130+
131+
it("correctly reconstructs an object from flattened attributes", () => {
132+
const flattened = {
133+
"level1.level2.value": "test",
134+
"level1.array.[0]": 1,
135+
"level1.array.[1]": 2,
136+
"level1.array.[2]": 3,
137+
};
138+
const expected = {
139+
level1: {
140+
level2: {
141+
value: "test",
142+
},
143+
array: [1, 2, 3],
144+
},
145+
};
146+
expect(unflattenAttributes(flattened)).toEqual(expected);
147+
});
148+
149+
it("handles complex nested objects with mixed types", () => {
150+
const flattened = {
151+
"user.details.name": "John Doe",
152+
"user.details.age": 30,
153+
"user.preferences.colors.[0]": "blue",
154+
"user.preferences.colors.[1]": "green",
155+
"user.active": true,
156+
};
157+
const expected = {
158+
user: {
159+
details: {
160+
name: "John Doe",
161+
age: 30,
162+
},
163+
preferences: {
164+
colors: ["blue", "green"],
165+
},
166+
active: true,
167+
},
168+
};
169+
expect(unflattenAttributes(flattened)).toEqual(expected);
170+
});
171+
172+
it("correctly identifies arrays vs objects", () => {
173+
const flattened = {
174+
"array.[0]": 1,
175+
"array.[1]": 2,
176+
"object.key": "value",
177+
};
178+
const expected = {
179+
array: [1, 2],
180+
object: {
181+
key: "value",
182+
},
183+
};
184+
expect(unflattenAttributes(flattened)).toEqual(expected);
185+
});
186+
});

0 commit comments

Comments
 (0)