forked from coder/code-server
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathretry.ts
More file actions
288 lines (251 loc) · 8.06 KB
/
retry.ts
File metadata and controls
288 lines (251 loc) · 8.06 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
import { logger, field } from "@coder/logger";
import { NotificationService, INotificationHandle, INotificationService, Severity } from "./fill/notification";
interface IRetryItem {
count?: number;
delay?: number; // In seconds.
end?: number; // In ms.
fn(): any | Promise<any>; // tslint:disable-line no-any can have different return values
timeout?: number | NodeJS.Timer;
running?: boolean;
showInNotification: boolean;
}
/**
* Retry services. Handles multiple services so when a connection drops the
* user doesn't get a separate notification for each service.
*
* Attempts to restart services silently up to a maximum number of tries, then
* starts waiting for a delay that grows exponentially with each attempt with a
* cap on the delay. Once the delay is long enough, it will show a notification
* to the user explaining what is happening with an option to immediately retry.
*/
export class Retry {
private items = new Map<string, IRetryItem>();
// Times are in seconds.
private readonly retryMinDelay = 1;
private readonly retryMaxDelay = 10;
private readonly maxImmediateRetries = 5;
private readonly retryExponent = 1.5;
private blocked: string | boolean | undefined;
private notificationHandle: INotificationHandle | undefined;
private readonly updateDelay = 1;
private updateTimeout: number | NodeJS.Timer | undefined;
private readonly notificationThreshold = 3;
// Time in milliseconds to wait before restarting a service. (See usage below
// for reasoning.)
private readonly waitDelay = 50;
public constructor(private _notificationService: INotificationService) {}
public set notificationService(service: INotificationService) {
this._notificationService = service;
}
public get notificationService(): INotificationService {
return this._notificationService;
}
/**
* Block retries when we know they will fail (for example when starting Wush
* back up). If a name is passed, that service will still be allowed to retry
* (unless we have already blocked).
*
* Blocking without a name will override a block with a name.
*/
public block(name?: string): void {
if (!this.blocked || !name) {
this.blocked = name || true;
this.items.forEach((item) => {
this.stopItem(item);
});
}
}
/**
* Unblock retries and run any that are pending.
*/
public unblock(): void {
this.blocked = false;
this.items.forEach((item, name) => {
if (item.running) {
this.runItem(name, item);
}
});
}
/**
* Register a function to retry that starts/connects to a service.
*
* If the function returns a promise, it will automatically be retried,
* recover, & unblock after calling `run` once (otherwise they need to be
* called manually).
*/
// tslint:disable-next-line no-any can have different return values
public register(name: string, fn: () => any | Promise<any>, showInNotification: boolean = true): void {
if (this.items.has(name)) {
throw new Error(`"${name}" is already registered`);
}
this.items.set(name, { fn, showInNotification });
}
/**
* Unregister a function to retry.
*/
public unregister(name: string): void {
if (!this.items.has(name)) {
throw new Error(`"${name}" is not registered`);
}
this.items.delete(name);
}
/**
* Retry a service.
*/
public run(name: string, error?: Error): void {
if (!this.items.has(name)) {
throw new Error(`"${name}" is not registered`);
}
const item = this.items.get(name)!;
if (item.running) {
throw new Error(`"${name}" is already retrying`);
}
item.running = true;
// This timeout is for the case when the connection drops; this allows time
// for the socket service to come in and block everything because some other
// services might make it here first and try to restart, which will fail.
setTimeout(() => {
if (this.blocked && this.blocked !== name) {
return;
}
if (!item.count || item.count < this.maxImmediateRetries) {
return this.runItem(name, item, error);
}
if (!item.delay) {
item.delay = this.retryMinDelay;
} else {
item.delay = Math.ceil(item.delay * this.retryExponent);
if (item.delay > this.retryMaxDelay) {
item.delay = this.retryMaxDelay;
}
}
logger.info(`Retrying ${name.toLowerCase()} in ${item.delay}s`, error && field("error", error.message));
const itemDelayMs = item.delay * 1000;
item.end = Date.now() + itemDelayMs;
item.timeout = setTimeout(() => this.runItem(name, item, error), itemDelayMs);
this.updateNotification();
}, this.waitDelay);
}
/**
* Reset a service after a successfully recovering.
*/
public recover(name: string): void {
if (!this.items.has(name)) {
throw new Error(`"${name}" is not registered`);
}
const item = this.items.get(name)!;
if (typeof item.timeout === "undefined" && !item.running && typeof item.count !== "undefined") {
logger.info(`Connected to ${name.toLowerCase()}`);
item.delay = undefined;
item.count = undefined;
}
}
/**
* Run an item.
*/
private runItem(name: string, item: IRetryItem, error?: Error): void {
if (!item.count) {
item.count = 1;
} else {
++item.count;
}
const retryCountText = item.count <= this.maxImmediateRetries
? `[${item.count}/${this.maxImmediateRetries}]`
: `[${item.count}]`;
logger.info(`Starting ${name.toLowerCase()} ${retryCountText}...`, error && field("error", error.message));
const endItem = (): void => {
this.stopItem(item);
item.running = false;
};
try {
const maybePromise = item.fn();
if (maybePromise instanceof Promise) {
maybePromise.then(() => {
endItem();
this.recover(name);
if (this.blocked === name) {
this.unblock();
}
}).catch(() => {
endItem();
this.run(name);
});
} else {
endItem();
}
} catch (error) {
// Prevent an exception from causing the item to never run again.
endItem();
throw error;
}
}
/**
* Update, close, or show the notification.
*/
private updateNotification(): void {
// tslint:disable-next-line no-any because NodeJS.Timer is valid.
clearTimeout(this.updateTimeout as any);
const now = Date.now();
const items = Array.from(this.items.entries()).filter(([_, item]) => {
return item.showInNotification
&& typeof item.end !== "undefined"
&& item.end > now
&& item.delay && item.delay >= this.notificationThreshold;
}).sort((a, b) => {
return a[1] < b[1] ? -1 : 1;
});
if (items.length === 0) {
if (this.notificationHandle) {
this.notificationHandle.close();
this.notificationHandle = undefined;
}
return;
}
const join = (arr: string[]): string => {
const last = arr.pop()!; // Assume length > 0.
return arr.length > 0 ? `${arr.join(", ")} and ${last}` : last;
};
const servicesStr = join(items.map(([name, _]) => name.toLowerCase()));
const message = `Lost connection to ${servicesStr}. Retrying in ${
join(items.map(([_, item]) => `${Math.ceil((item.end! - now) / 1000)}s`))
}.`;
const buttons = [{
label: `Retry ${items.length > 1 ? "Services" : items[0][0]} Now`,
run: (): void => {
logger.info(`Forcing ${servicesStr} to restart now`);
items.forEach(([name, item]) => {
this.runItem(name, item);
});
this.updateNotification();
},
}];
if (!this.notificationHandle) {
this.notificationHandle = this.notificationService.prompt(
Severity.Info,
message,
buttons,
() => {
this.notificationHandle = undefined;
// tslint:disable-next-line no-any because NodeJS.Timer is valid.
clearTimeout(this.updateTimeout as any);
},
);
} else {
this.notificationHandle.updateMessage(message);
this.notificationHandle.updateButtons(buttons);
}
this.updateTimeout = setTimeout(() => this.updateNotification(), this.updateDelay * 1000);
}
/**
* Stop an item's timer.
*/
private stopItem(item: IRetryItem): void {
// tslint:disable-next-line no-any because NodeJS.Timer is valid.
clearTimeout(item.timeout as any);
item.timeout = undefined;
item.end = undefined;
}
}
// Global instance so we can block other retries when retrying the main
// connection.
export const retry = new Retry(new NotificationService());