Skip to content

Commit f28ff0f

Browse files
committed
Add retrier interface and exponential backoff class.
1 parent c92ac91 commit f28ff0f

4 files changed

Lines changed: 246 additions & 0 deletions

File tree

packages/databricks/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
"types": "./dist/index.d.ts",
1111
"import": "./dist/index.js"
1212
},
13+
"./api": {
14+
"types": "./dist/api/index.d.ts",
15+
"import": "./dist/api/index.js"
16+
},
1317
"./apierror": {
1418
"types": "./dist/apierror/index.d.ts",
1519
"import": "./dist/apierror/index.js"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* Databricks API client utilities.
3+
*
4+
* @packageDocumentation
5+
*/
6+
7+
export {BackoffPolicy, retryOn} from './retrier';
8+
export type {BackoffPolicyOptions, Retrier} from './retrier';
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/** Options for configuring a {@link BackoffPolicy}. */
2+
export interface BackoffPolicyOptions {
3+
/** Initial delay in milliseconds; defaults to 1000. */
4+
initial?: number;
5+
6+
/** Maximum delay in milliseconds; defaults to 60000. */
7+
maximum?: number;
8+
9+
/**
10+
* Factor by which the delay is multiplied after each retry. The value must
11+
* be greater or equal to 1. If not, it defaults to 2.
12+
*/
13+
factor?: number;
14+
}
15+
16+
// Random number generation, wrapped in an object for testability.
17+
export const rand = {
18+
// Returns a random integer in [0, n).
19+
int(n: number): number {
20+
return Math.floor(Math.random() * n);
21+
},
22+
};
23+
24+
/**
25+
* BackoffPolicy implements an exponential backoff policy. The delay between
26+
* retries is randomly computed between 0 and the "exponential delay" as
27+
* recommended in [Exponential Backoff And Jitter]. The retry delay starts from
28+
* initial and grows exponentially by factor at every retry. The maximum retry
29+
* delay is capped by maximum.
30+
*
31+
* There is no parameter to limit the number of retries. This is intended as
32+
* such logic should be implemented upstream (e.g. in a Retrier).
33+
*
34+
* [Exponential Backoff And Jitter]: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
35+
*/
36+
export class BackoffPolicy {
37+
/** Initial delay in milliseconds. */
38+
readonly initial: number;
39+
40+
/** Maximum delay in milliseconds. */
41+
readonly maximum: number;
42+
43+
/** Factor by which the delay is multiplied after each retry. */
44+
readonly factor: number;
45+
46+
// Current delay before the next retry.
47+
private current: number;
48+
49+
constructor(options?: BackoffPolicyOptions) {
50+
let initial = options?.initial ?? 1000; // Default initial delay of 1 second.
51+
const maximum = options?.maximum ?? 60000; // Default maximum delay of 60 seconds.
52+
53+
if (initial > maximum) {
54+
// Initial cannot be greater than maximum.
55+
initial = maximum;
56+
}
57+
58+
this.initial = initial;
59+
this.maximum = maximum;
60+
this.factor =
61+
options?.factor !== undefined && options.factor >= 1 ? options.factor : 2;
62+
this.current = this.initial;
63+
}
64+
65+
/** Returns a random delay in [0, current] and grows the current delay. */
66+
delay(): number {
67+
// Random duration in the range [0, this.current].
68+
const d = rand.int(this.current + 1);
69+
70+
// Grow delay for the next call.
71+
this.current = Math.min(this.current * this.factor, this.maximum);
72+
73+
return d;
74+
}
75+
}
76+
77+
/** Retrier defines a retry behavior. */
78+
export interface Retrier {
79+
/**
80+
* Returns the delay in milliseconds before the next retry, or undefined if
81+
* the error is not retriable. Implementations should assume that the given
82+
* error is never undefined.
83+
*/
84+
isRetriable(err: Error): number | undefined;
85+
}
86+
87+
/**
88+
* Returns a Retrier that retries based on the isRetriable predicate and relies
89+
* on an internal backoff policy to decide how long to wait between retries.
90+
*
91+
* Important: the retrier has its own backoff policy which cannot be trivially
92+
* reset by design. Users who need to reset the backoff policy should rather
93+
* create a new retrier.
94+
*/
95+
export function retryOn(
96+
options: BackoffPolicyOptions,
97+
isRetriable: (err: Error) => boolean
98+
): Retrier {
99+
const bp = new BackoffPolicy(options);
100+
return {
101+
isRetriable(err: Error): number | undefined {
102+
if (!isRetriable(err)) {
103+
return undefined;
104+
}
105+
return bp.delay();
106+
},
107+
};
108+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import {describe, it, expect, vi} from 'vitest';
2+
import {BackoffPolicy, rand, retryOn} from '../../src/api/retrier';
3+
4+
// Always returns the maximum value (n - 1) for deterministic tests.
5+
function deterministicRand(n: number): number {
6+
return n - 1;
7+
}
8+
9+
describe('retryOn isRetriable', () => {
10+
const testCases: {
11+
name: string;
12+
fn: (err: Error) => boolean;
13+
wantDelay: number | undefined;
14+
}[] = [
15+
{
16+
name: 'retriable returns delay',
17+
fn: () => true,
18+
wantDelay: 99,
19+
},
20+
{
21+
name: 'not retriable returns undefined',
22+
fn: () => false,
23+
wantDelay: undefined,
24+
},
25+
];
26+
27+
it.each(testCases)('$name', ({fn, wantDelay}) => {
28+
vi.spyOn(rand, 'int').mockImplementation(deterministicRand);
29+
const r = retryOn({initial: wantDelay ?? 0}, fn);
30+
31+
const got = r.isRetriable(new Error('an error'));
32+
if (wantDelay === undefined) {
33+
expect(got).toBeUndefined();
34+
} else {
35+
expect(got).toBe(wantDelay);
36+
}
37+
vi.restoreAllMocks();
38+
});
39+
});
40+
41+
describe('BackoffPolicy defaults', () => {
42+
const testCases: {
43+
name: string;
44+
options?: {
45+
initial?: number;
46+
maximum?: number;
47+
factor?: number;
48+
};
49+
wantInitial: number;
50+
wantMaximum: number;
51+
wantFactor: number;
52+
}[] = [
53+
{
54+
name: 'default',
55+
wantInitial: 1000,
56+
wantMaximum: 60000,
57+
wantFactor: 2,
58+
},
59+
{
60+
name: 'custom initial smaller than maximum',
61+
options: {initial: 100},
62+
wantInitial: 100,
63+
wantMaximum: 60000,
64+
wantFactor: 2,
65+
},
66+
{
67+
name: 'custom initial greater than maximum',
68+
options: {initial: 10000, maximum: 1000},
69+
wantInitial: 1000,
70+
wantMaximum: 1000,
71+
wantFactor: 2,
72+
},
73+
{
74+
name: 'custom factor less than 1',
75+
options: {factor: 0.5},
76+
wantInitial: 1000,
77+
wantMaximum: 60000,
78+
wantFactor: 2,
79+
},
80+
{
81+
name: 'custom factor greater than 1',
82+
options: {factor: 1.5},
83+
wantInitial: 1000,
84+
wantMaximum: 60000,
85+
wantFactor: 1.5,
86+
},
87+
];
88+
89+
it.each(testCases)(
90+
'$name',
91+
({options, wantInitial, wantMaximum, wantFactor}) => {
92+
const bp = new BackoffPolicy(options);
93+
94+
expect(bp.initial).toBe(wantInitial);
95+
expect(bp.maximum).toBe(wantMaximum);
96+
expect(bp.factor).toBe(wantFactor);
97+
}
98+
);
99+
});
100+
101+
describe('BackoffPolicy exponential delay', () => {
102+
it('should grow exponentially and cap at maximum', () => {
103+
vi.spyOn(rand, 'int').mockImplementation(deterministicRand);
104+
const bp = new BackoffPolicy({
105+
initial: 100,
106+
maximum: 10000,
107+
factor: 2.0,
108+
});
109+
110+
const wantDelays = [
111+
100,
112+
200,
113+
400,
114+
800,
115+
1600,
116+
3200,
117+
6400,
118+
10000, // Capped by maximum.
119+
];
120+
121+
for (const want of wantDelays) {
122+
expect(bp.delay()).toBe(want);
123+
}
124+
vi.restoreAllMocks();
125+
});
126+
});

0 commit comments

Comments
 (0)