Skip to content

Commit 87890fc

Browse files
committed
feat(ec2-metadata-service): add retries
1 parent c860bd5 commit 87890fc

File tree

3 files changed

+191
-54
lines changed

3 files changed

+191
-54
lines changed

packages/ec2-metadata-service/src/MetadataService.spec.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NodeHttpHandler } from "@smithy/node-http-handler";
22
import { Readable } from "stream";
3-
import { beforeEach, describe, expect, test as it, vi } from "vitest";
3+
import { afterEach, beforeEach, describe, expect, test as it, vi } from "vitest";
44

55
import { MetadataService } from "./MetadataService";
66

@@ -24,6 +24,7 @@ describe("MetadataService Socket Leak Checks", () => {
2424
metadataService = new MetadataService({
2525
endpoint: "http://169.254.169.254",
2626
httpOptions: { timeout: 1000 },
27+
retries: 0, // Disable retries for faster tests
2728
});
2829
});
2930

@@ -276,6 +277,7 @@ describe("MetadataService Custom Ports", () => {
276277
metadataService = new MetadataService({
277278
endpoint: "http://localhost:1338",
278279
httpOptions: { timeout: 1000 },
280+
retries: 0, // Disable retries for faster tests
279281
});
280282

281283
const mockResponse = createMockResponse(200, "i-1234567890abcdef0");
@@ -294,6 +296,7 @@ describe("MetadataService Custom Ports", () => {
294296
metadataService = new MetadataService({
295297
endpoint: "http://localhost:1338",
296298
httpOptions: { timeout: 1000 },
299+
retries: 0, // Disable retries for faster tests
297300
});
298301

299302
const mockResponse = createMockResponse(200, "test-token-123");
@@ -312,6 +315,7 @@ describe("MetadataService Custom Ports", () => {
312315
metadataService = new MetadataService({
313316
endpoint: "http://169.254.169.254",
314317
httpOptions: { timeout: 1000 },
318+
retries: 0, // Disable retries for faster tests
315319
});
316320

317321
const mockResponse = createMockResponse(200, "test-token-123");
@@ -326,3 +330,68 @@ describe("MetadataService Custom Ports", () => {
326330
expect(requestArg.hostname).toBe("169.254.169.254");
327331
});
328332
});
333+
334+
describe("MetadataService Retry Configuration", () => {
335+
it("should use default 3 retries", () => {
336+
const metadataService = new MetadataService();
337+
expect((metadataService as any).retries).toBe(3);
338+
});
339+
340+
it("should use custom retry count", () => {
341+
const metadataService = new MetadataService({ retries: 5 });
342+
expect((metadataService as any).retries).toBe(5);
343+
});
344+
345+
it("should disable retries when set to 0", () => {
346+
const metadataService = new MetadataService({ retries: 0 });
347+
expect((metadataService as any).retries).toBe(0);
348+
});
349+
350+
it("should create backoff function", () => {
351+
const metadataService = new MetadataService();
352+
const backoffFn = (metadataService as any).backoffFn;
353+
expect(typeof backoffFn).toBe("function");
354+
});
355+
356+
describe("status code handling for retries", () => {
357+
it("should not retry 400 errors", async () => {
358+
const metadataService = new MetadataService({ retries: 0 });
359+
const shouldNotRetry = (metadataService as any).shouldNotRetry;
360+
361+
const error = { statusCode: 400 };
362+
expect(shouldNotRetry(error)).toBe(true);
363+
});
364+
365+
it("should not retry 403 errors", async () => {
366+
const metadataService = new MetadataService({ retries: 0 });
367+
const shouldNotRetry = (metadataService as any).shouldNotRetry;
368+
369+
const error = { statusCode: 403 };
370+
expect(shouldNotRetry(error)).toBe(true);
371+
});
372+
373+
it("should not retry 404 errors", async () => {
374+
const metadataService = new MetadataService({ retries: 0 });
375+
const shouldNotRetry = (metadataService as any).shouldNotRetry;
376+
377+
const error = { statusCode: 404 };
378+
expect(shouldNotRetry(error)).toBe(true);
379+
});
380+
381+
it("should retry 401 errors", async () => {
382+
const metadataService = new MetadataService({ retries: 0 });
383+
const shouldNotRetry = (metadataService as any).shouldNotRetry;
384+
385+
const error = { statusCode: 401 };
386+
expect(shouldNotRetry(error)).toBe(false);
387+
});
388+
389+
it("should retry 500 errors", async () => {
390+
const metadataService = new MetadataService({ retries: 0 });
391+
const shouldNotRetry = (metadataService as any).shouldNotRetry;
392+
393+
const error = { statusCode: 500 };
394+
expect(shouldNotRetry(error)).toBe(false);
395+
});
396+
});
397+
});

packages/ec2-metadata-service/src/MetadataService.ts

Lines changed: 113 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { MetadataServiceOptions } from "./MetadataServiceOptions";
1313
export class MetadataService {
1414
private disableFetchToken: boolean;
1515
private config: Promise<MetadataServiceOptions>;
16+
private retries: number;
17+
private backoffFn: (numFailures: number) => void;
1618

1719
/**
1820
* Creates a new MetadataService object with a given set of options.
@@ -30,68 +32,123 @@ export class MetadataService {
3032
};
3133
})();
3234
this.disableFetchToken = options?.disableFetchToken || false;
35+
this.retries = options?.retries ?? 3;
36+
this.backoffFn = this.createBackoffFunction(options?.backoff);
3337
}
3438

35-
async request(path: string, options: { method?: string; headers?: Record<string, string> }): Promise<string> {
36-
const { endpoint, ec2MetadataV1Disabled, httpOptions } = await this.config;
37-
const handler = new NodeHttpHandler({
38-
requestTimeout: httpOptions?.timeout,
39-
throwOnRequestTimeout: true,
40-
connectionTimeout: httpOptions?.timeout,
41-
});
42-
const endpointUrl = new URL(endpoint!);
43-
const headers = options.headers || {};
44-
/**
45-
* If IMDSv1 is disabled and disableFetchToken is true, throw an error
46-
* Refer: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html
47-
*/
48-
if (this.disableFetchToken && ec2MetadataV1Disabled) {
49-
throw new Error("IMDSv1 is disabled and fetching token is disabled, cannot make the request.");
39+
private createBackoffFunction(backoff?: number | ((numFailures: number) => void)): (numFailures: number) => void {
40+
if (typeof backoff === "function") {
41+
return backoff;
5042
}
51-
/**
52-
* Make request with token if disableFetchToken is not true (IMDSv2).
53-
* Note that making the request call with token will result in an additional request to fetch the token.
54-
*/
55-
if (!this.disableFetchToken) {
43+
if (typeof backoff === "number") {
44+
return () => this.sleep(backoff * 1000);
45+
}
46+
// Default exponential backoff
47+
return (numFailures: number) => this.sleep(Math.pow(1.2, numFailures) * 1000);
48+
}
49+
50+
private sleep(ms: number): Promise<void> {
51+
return new Promise((resolve) => setTimeout(resolve, ms));
52+
}
53+
54+
private async retryWithBackoff<T>(operation: () => Promise<T>): Promise<T> {
55+
let lastError: Error;
56+
57+
for (let attempt = 0; attempt <= this.retries; attempt++) {
5658
try {
57-
headers["x-aws-ec2-metadata-token"] = await this.fetchMetadataToken();
58-
} catch (err) {
59-
if (ec2MetadataV1Disabled) {
60-
// If IMDSv1 is disabled and token fetch fails (IMDSv2 fails), rethrow the error
61-
throw err;
59+
return await operation();
60+
} catch (error) {
61+
lastError = error as Error;
62+
63+
// Don't retry on final attempt
64+
if (attempt === this.retries) {
65+
break;
6266
}
63-
// If token fetch fails and IMDSv1 is not disabled, proceed without token (IMDSv1 fallback)
67+
68+
if (this.shouldNotRetry(error as any)) {
69+
throw error;
70+
}
71+
72+
await this.backoffFn(attempt);
6473
}
65-
} // else, IMDSv1 fallback mode
66-
const request = new HttpRequest({
67-
method: options.method || "GET", // Default to GET if no method is specified
68-
headers: headers,
69-
hostname: endpointUrl.hostname,
70-
path: endpointUrl.pathname + path,
71-
protocol: endpointUrl.protocol,
72-
port: endpointUrl.port ? parseInt(endpointUrl.port) : undefined,
73-
});
74-
try {
75-
const { response } = await handler.handle(request, {} as HttpHandlerOptions);
76-
if (response.statusCode === 200 && response.body) {
77-
// handle response.body as stream
78-
return sdkStreamMixin(response.body).transformToString();
79-
} else {
80-
throw Object.assign(new Error(`Request failed with status code ${response.statusCode}`), {
81-
$metadata: { httpStatusCode: response.statusCode },
82-
});
74+
}
75+
76+
throw lastError!;
77+
}
78+
79+
private shouldNotRetry(error: any): boolean {
80+
// 400/403 errors for token fetch MUST NOT be retried
81+
// 404 errors for metadata fetch MUST NOT be retried
82+
const statusCode = error.statusCode || error.$metadata?.httpStatusCode;
83+
return statusCode === 400 || statusCode === 403 || statusCode === 404;
84+
}
85+
86+
async request(path: string, options: { method?: string; headers?: Record<string, string> }): Promise<string> {
87+
return this.retryWithBackoff(async () => {
88+
const { endpoint, ec2MetadataV1Disabled, httpOptions } = await this.config;
89+
const handler = new NodeHttpHandler({
90+
requestTimeout: httpOptions?.timeout,
91+
throwOnRequestTimeout: true,
92+
connectionTimeout: httpOptions?.timeout,
93+
});
94+
const endpointUrl = new URL(endpoint!);
95+
const headers = options.headers || {};
96+
/**
97+
* If IMDSv1 is disabled and disableFetchToken is true, throw an error
98+
* Refer: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html
99+
*/
100+
if (this.disableFetchToken && ec2MetadataV1Disabled) {
101+
throw new Error("IMDSv1 is disabled and fetching token is disabled, cannot make the request.");
83102
}
84-
} catch (error) {
85-
const wrappedError = new Error(`Error making request to the metadata service: ${error}`);
86-
const { $metadata } = error as any;
87-
if ($metadata?.httpStatusCode !== undefined) {
88-
Object.assign(wrappedError, { $metadata });
103+
/**
104+
* Make request with token if disableFetchToken is not true (IMDSv2).
105+
* Note that making the request call with token will result in an additional request to fetch the token.
106+
*/
107+
if (!this.disableFetchToken) {
108+
try {
109+
headers["x-aws-ec2-metadata-token"] = await this.fetchMetadataTokenInternal();
110+
} catch (err) {
111+
if (ec2MetadataV1Disabled) {
112+
// If IMDSv1 is disabled and token fetch fails (IMDSv2 fails), rethrow the error
113+
throw err;
114+
}
115+
// If token fetch fails and IMDSv1 is not disabled, proceed without token (IMDSv1 fallback)
116+
}
117+
} // else, IMDSv1 fallback mode
118+
const request = new HttpRequest({
119+
method: options.method || "GET", // Default to GET if no method is specified
120+
headers: headers,
121+
hostname: endpointUrl.hostname,
122+
path: endpointUrl.pathname + path,
123+
protocol: endpointUrl.protocol,
124+
port: endpointUrl.port ? parseInt(endpointUrl.port) : undefined,
125+
});
126+
try {
127+
const { response } = await handler.handle(request, {} as HttpHandlerOptions);
128+
if (response.statusCode === 200 && response.body) {
129+
// handle response.body as stream
130+
return sdkStreamMixin(response.body).transformToString();
131+
} else {
132+
throw Object.assign(new Error(`Request failed with status code ${response.statusCode}`), {
133+
$metadata: { httpStatusCode: response.statusCode },
134+
});
135+
}
136+
} catch (error) {
137+
const wrappedError = new Error(`Error making request to the metadata service: ${error}`);
138+
const { $metadata } = error as any;
139+
if ($metadata?.httpStatusCode !== undefined) {
140+
Object.assign(wrappedError, { $metadata });
141+
}
142+
throw wrappedError;
89143
}
90-
throw wrappedError;
91-
}
144+
});
92145
}
93146

94147
async fetchMetadataToken(): Promise<string> {
148+
return this.retryWithBackoff(() => this.fetchMetadataTokenInternal());
149+
}
150+
151+
private async fetchMetadataTokenInternal(): Promise<string> {
95152
/**
96153
* Refer: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-metadata-v2-how-it-works.html
97154
*/
@@ -124,11 +181,14 @@ export class MetadataService {
124181
return bodyString;
125182
} else {
126183
throw Object.assign(new Error(`Failed to fetch metadata token with status code ${response.statusCode}`), {
127-
statusCode: response.statusCode,
184+
$metadata: { httpStatusCode: response.statusCode },
128185
});
129186
}
130187
} catch (error) {
131-
if (error.message === "TimeoutError" || [403, 404, 405].includes(error.statusCode)) {
188+
if (
189+
error.message === "TimeoutError" ||
190+
[403, 404, 405].includes((error as any).statusCode || (error as any).$metadata?.httpStatusCode)
191+
) {
132192
this.disableFetchToken = true; // as per JSv2 and fromInstanceMetadata implementations
133193
throw new Error(`Error fetching metadata token: ${error}. [disableFetchToken] is now set to true.`);
134194
}

packages/ec2-metadata-service/src/MetadataServiceOptions.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,12 @@ export interface MetadataServiceOptions {
2727
* when true, metadata service will not fetch token, which indicates usage of IMDSv1
2828
*/
2929
disableFetchToken?: boolean;
30+
/**
31+
* the number of retry attempts for any failed request, defaulting to 3.
32+
*/
33+
retries?: number;
34+
/**
35+
* the number of seconds to sleep in-between retries and/or a customer provided backoff function to call.
36+
*/
37+
backoff?: number | ((numFailures: number) => void);
3038
}

0 commit comments

Comments
 (0)