Skip to content

Commit

Permalink
makeBackoffMachine: Add, for replacement of progressiveTimeout.
Browse files Browse the repository at this point in the history
Improve state logic for sleep durations in API request retry loops.

progressiveTimeout used a global state, which had the benefit of
enabling a general throttle affecting all request retry loops that
used it. But different requests may have transient failures for
different reasons, so per-request state handling makes more sense,
and high request traffic is still mitigated by exponential backoff.

Also, previously, any call to progressiveTimeout was
nondeterministic, because other calls may have been done recently,
affecting the delay duration. A 60-second threshold was used as a
heuristic to distinguish request retry loops from each other, but
this is more effectively done by managing the state per-loop in the
first place, and the next sleep duration becomes a pure function of
the number of sleeps completed.

Preparation for zulip#3829.
  • Loading branch information
Chris Bobbe committed Feb 5, 2020
1 parent 4724331 commit 1b17a8a
Show file tree
Hide file tree
Showing 2 changed files with 84 additions and 0 deletions.
32 changes: 32 additions & 0 deletions src/utils/__tests__/makeBackoffMachine-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* @flow strict-local */
import { makeBackoffMachine } from '../async';

describe('makeBackoffMachine', () => {
test('timeouts are ~100ms, ~200ms, ~400ms, ~800ms', async () => {
const backoffMachine = makeBackoffMachine();

const start0 = Date.now();
await backoffMachine.wait();
const duration0 = Date.now() - start0;
expect(duration0).toBeGreaterThan(90);
expect(duration0).toBeLessThan(110);

const start1 = Date.now();
await backoffMachine.wait();
const duration1 = Date.now() - start1;
expect(duration1).toBeGreaterThan(190);
expect(duration1).toBeLessThan(210);

const start2 = Date.now();
await backoffMachine.wait();
const duration2 = Date.now() - start2;
expect(duration2).toBeGreaterThan(390);
expect(duration2).toBeLessThan(410);

const start3 = Date.now();
await backoffMachine.wait();
const duration3 = Date.now() - start3;
expect(duration3).toBeGreaterThan(790);
expect(duration3).toBeLessThan(810);
});
});
52 changes: 52 additions & 0 deletions src/utils/async.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,58 @@ export function delay<T>(callback: () => T): Promise<T> {
export const sleep = (ms: number = 0): Promise<void> =>
new Promise(resolve => setTimeout(resolve, ms));

type BackoffMachine = {
waitsCompleted: () => number,
wait: () => Promise<void>,
};

/**
* Makes a machine that can sleep for a timeout that, until a ceiling is reached,
* grows exponentially in duration with the number of sleeps completed, with a
* base of 2. The machine should be created before a loop starts, and .wait()
* should be called in each iteration of the loop. The machine should not be
* re-used after exiting the loop.
*
* The .waitsCompleted() getter can be used if the caller needs to implement
* "give up" logic for a network request by breaking out of the loop after a
* certain number of tries.
*
* E.g., if firstDuration is 100 and durationCeiling is 10 * 1000 = 10000,
* the sequence is
*
* 100, 200, 400, 800, 1600, 3200, 6400, 10000, 10000, 10000, ...
*/
export const makeBackoffMachine = (): BackoffMachine => {
const firstDuration = 100;
const durationCeiling = 10 * 1000;
const base = 2;

let startTime: number | void;
let waitsCompleted: number = 0;

return {
waitsCompleted() {
return waitsCompleted;
},

async wait(): Promise<void> {
if (startTime === undefined) {
startTime = Date.now();
}

const duration = Math.min(
// Should not exceed durationCeiling
durationCeiling,
firstDuration * base ** waitsCompleted,
);

await sleep(duration);

waitsCompleted++;
},
};
};

/**
* Calls an async function and if unsuccessful retries the call.
*
Expand Down

0 comments on commit 1b17a8a

Please sign in to comment.