Skip to content

Commit 664759c

Browse files
Merge pull request #234 from OctopusDeploy/huy/sc-124345-add-retry-for-task-await
Add getTasksWithRetry with retry logic for transient network errors
2 parents e239504 + e266634 commit 664759c

File tree

2 files changed

+112
-10
lines changed

2 files changed

+112
-10
lines changed

src/features/serverTasks/serverTaskWaiter.ts

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,64 @@
1+
/* eslint-disable no-eq-null */
2+
/* eslint-disable @typescript-eslint/consistent-type-assertions */
3+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
14
import { Client } from "../..";
25
import { ServerTask } from "../../features/serverTasks";
36
import { SpaceServerTaskRepository } from "../serverTasks";
47
import { ServerTaskRepository } from "../serverTasks";
58

9+
export interface ServerTaskWaiterOptions {
10+
maxRetries?: number; // Default: 3
11+
retryBackoffMs?: number; // Initial backoff in ms, default: 5000
12+
}
13+
614
export class ServerTaskWaiter {
7-
constructor(private readonly client: Client, private readonly spaceName: string) {}
15+
private readonly maxRetries: number;
16+
private readonly retryBackoffMs: number;
17+
18+
constructor(private readonly client: Client, private readonly spaceName: string, options?: ServerTaskWaiterOptions) {
19+
this.maxRetries = options?.maxRetries ?? 3;
20+
this.retryBackoffMs = options?.retryBackoffMs ?? 5000;
21+
}
822

923
async waitForServerTasksToComplete(
1024
serverTaskIds: string[],
1125
statusCheckSleepCycle: number,
1226
timeout: number,
1327
pollingCallback?: (serverTask: ServerTask) => void,
14-
cancelOnTimeout: boolean = false,
28+
cancelOnTimeout: boolean = false
1529
): Promise<ServerTask[]> {
1630
const spaceServerTaskRepository = new SpaceServerTaskRepository(this.client, this.spaceName);
17-
const serverTaskRepository = new ServerTaskRepository(this.client)
18-
19-
return this.waitForTasks(spaceServerTaskRepository, serverTaskRepository, serverTaskIds, statusCheckSleepCycle, timeout, cancelOnTimeout, pollingCallback);
31+
const serverTaskRepository = new ServerTaskRepository(this.client);
32+
33+
return this.waitForTasks(
34+
spaceServerTaskRepository,
35+
serverTaskRepository,
36+
serverTaskIds,
37+
statusCheckSleepCycle,
38+
timeout,
39+
cancelOnTimeout,
40+
pollingCallback
41+
);
2042
}
2143

2244
async waitForServerTaskToComplete(
2345
serverTaskId: string,
2446
statusCheckSleepCycle: number,
2547
timeout: number,
2648
pollingCallback?: (serverTask: ServerTask) => void,
27-
cancelOnTimeout: boolean = false,
49+
cancelOnTimeout: boolean = false
2850
): Promise<ServerTask> {
2951
const spaceServerTaskRepository = new SpaceServerTaskRepository(this.client, this.spaceName);
30-
const serverTaskRepository = new ServerTaskRepository(this.client)
31-
const tasks = await this.waitForTasks(spaceServerTaskRepository, serverTaskRepository, [serverTaskId], statusCheckSleepCycle, timeout, cancelOnTimeout, pollingCallback);
52+
const serverTaskRepository = new ServerTaskRepository(this.client);
53+
const tasks = await this.waitForTasks(
54+
spaceServerTaskRepository,
55+
serverTaskRepository,
56+
[serverTaskId],
57+
statusCheckSleepCycle,
58+
timeout,
59+
cancelOnTimeout,
60+
pollingCallback
61+
);
3262
return tasks[0];
3363
}
3464

@@ -61,7 +91,7 @@ export class ServerTaskWaiter {
6191

6292
try {
6393
while (!stop) {
64-
const tasks = await spaceServerTaskRepository.getByIds(serverTaskIds);
94+
const tasks = await this.getTasksWithRetry(spaceServerTaskRepository, serverTaskIds);
6595

6696
const unknownTaskIds = serverTaskIds.filter((id) => tasks.filter((t) => t.Id === id).length == 0);
6797
if (unknownTaskIds.length) {
@@ -93,6 +123,7 @@ export class ServerTaskWaiter {
93123

94124
await sleep(statusCheckSleepCycle);
95125
}
126+
96127
if (timedOut && cancelOnTimeout && serverTaskIds.length > 0) {
97128
await this.cancelTasks(serverTaskRepository, serverTaskIds);
98129
}
@@ -115,4 +146,72 @@ export class ServerTaskWaiter {
115146
}
116147
}
117148
}
149+
150+
private async getTasksWithRetry(repository: SpaceServerTaskRepository, taskIds: string[]): Promise<ServerTask[]> {
151+
// eslint-disable-next-line @typescript-eslint/init-declarations
152+
let lastError: any;
153+
154+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
155+
try {
156+
return await repository.getByIds(taskIds);
157+
} catch (error) {
158+
lastError = error;
159+
const errorMessage = error instanceof Error ? error.message : String(error);
160+
161+
const statusCode =
162+
(error as any).StatusCode ||
163+
(typeof (error as any).code === "number" ? (error as any).code : null) ||
164+
(error as any).response?.status ||
165+
(error as any).status;
166+
167+
const isRetryable = this.isRetryableError(error, statusCode);
168+
169+
if (!isRetryable) throw error;
170+
171+
if (attempt === this.maxRetries)
172+
throw new Error(`Failed to connect to Octopus server after ${this.maxRetries} attempts. ` + `Last error: ${errorMessage}`);
173+
174+
const backoffDelay = this.retryBackoffMs * Math.pow(2, attempt);
175+
this.client.warn(
176+
`HTTP request failed (attempt ${attempt + 1}/${this.maxRetries}): ${errorMessage}${
177+
statusCode ? ` [${statusCode}]` : ""
178+
}. Retrying in ${backoffDelay}ms...`
179+
);
180+
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
181+
}
182+
}
183+
184+
// This should never be reached due to throws above, but TypeScript needs it
185+
throw lastError;
186+
}
187+
188+
private isRetryableError(error: any, statusCode: number | null): boolean {
189+
if (!error) return false;
190+
191+
if (statusCode && [408, 429, 500, 502, 503, 504].includes(statusCode)) {
192+
return true;
193+
}
194+
195+
try {
196+
const errorStr = String(error.message || error).toLowerCase();
197+
const errorCode = error.code ? String(error.code).toLowerCase() : "";
198+
const keywords = [
199+
"timeout",
200+
"etimedout",
201+
"econnreset",
202+
"econnrefused",
203+
"econnaborted",
204+
"enotfound",
205+
"eai_again",
206+
"epipe",
207+
"ehostunreach",
208+
"enetunreach",
209+
"socket",
210+
"network",
211+
];
212+
return keywords.some((k) => errorStr.includes(k) || errorCode.includes(k));
213+
} catch {
214+
return false;
215+
}
216+
}
118217
}

src/features/serverTasks/spaceServerTaskRepository.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ export class SpaceServerTaskRepository {
3838
})
3939
);
4040
}
41-
return Promise.allSettled(promises).then((result) => flatMap(result, (c) => (c.status == "fulfilled" ? c.value.Items : [])));
41+
// Changed from Promise.allSettled to Promise.all
42+
// Errors will now propagate instead of being swallowed, allowing retry logic at higher levels
43+
const results = await Promise.all(promises);
44+
return flatMap(results, (c) => c.Items);
4245
}
4346

4447
async getDetails(serverTaskId: string): Promise<ServerTaskDetails> {

0 commit comments

Comments
 (0)