Skip to content

Commit 264f830

Browse files
authored
fix #50: add exponential backoff for 429 calls (#67)
* fix #50: add exponential backoff for 429 calls
1 parent 0cff76e commit 264f830

File tree

2 files changed

+261
-0
lines changed

2 files changed

+261
-0
lines changed

analyzer/tools/auth0.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,52 @@ const PER_PAGE = 100;
1010
axios.defaults.headers.common["User-Agent"] =
1111
`${packageName}/${packageVersion}`;
1212

13+
// Add exponential backoff interceptor
14+
axios.interceptors.response.use(
15+
(response) => response,
16+
async (error) => {
17+
const config = error.config;
18+
19+
// Retry on 429 (Too Many Requests)
20+
if (error.response && error.response.status === 429) {
21+
config.__retryCount = config.__retryCount || 0;
22+
const MAX_RETRIES = 5;
23+
24+
if (config.__retryCount >= MAX_RETRIES) {
25+
return Promise.reject(error);
26+
}
27+
28+
// Calculate delay
29+
let delayInMs = 1000;
30+
if (error.response.headers["retry-after"]) {
31+
//usually Auth0 provides seconds in retry-after header
32+
const retryAfter = parseInt(error.response.headers["retry-after"], 10);
33+
if (!isNaN(retryAfter)) {
34+
delayInMs = retryAfter * 1000;
35+
}
36+
} else {
37+
// In case Auth0 changes retry-after header
38+
delayInMs = Math.pow(2, config.__retryCount) * 1000;
39+
}
40+
41+
// Add some jitter to prevent thundering herd
42+
delayInMs += Math.random() * 1000;
43+
44+
logger.log(
45+
"warn",
46+
`Rate limited. Retrying in ${Math.round(delayInMs)}ms...
47+
(Attempt ${config.__retryCount}/${MAX_RETRIES}) due to ${error.response.status} response`,
48+
);
49+
50+
config.__retryCount += 1;
51+
await new Promise((resolve) => setTimeout(resolve, delayInMs));
52+
return axios(config);
53+
}
54+
55+
return Promise.reject(error);
56+
}
57+
);
58+
1359
async function getAccessToken(domain, client_id, client_secret, access_token) {
1460
if (access_token) {
1561
return access_token;

tests/tools/auth0_retry.test.js

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
const { expect } = require("chai");
2+
const axios = require("axios");
3+
const logger = require("../../analyzer/lib/logger");
4+
// We need to require the file to ensure the interceptor is attached
5+
require("../../analyzer/tools/auth0");
6+
7+
describe("Auth0 API Retry Logic", function() {
8+
let originalAdapter;
9+
let originalLoggerLog;
10+
let logSpy = [];
11+
12+
beforeEach(function() {
13+
originalAdapter = axios.defaults.adapter;
14+
originalLoggerLog = logger.log;
15+
logSpy = [];
16+
logger.log = (level, message) => {
17+
logSpy.push({ level, message });
18+
};
19+
});
20+
21+
afterEach(function() {
22+
axios.defaults.adapter = originalAdapter;
23+
logger.log = originalLoggerLog;
24+
});
25+
26+
it("should retry on 429 and eventually succeed", async function() {
27+
let attempts = 0;
28+
29+
// Mock adapter that fails twice with 429 then succeeds
30+
axios.defaults.adapter = async (config) => {
31+
attempts++;
32+
if (attempts <= 2) {
33+
const error = new Error("Request failed with status code 429");
34+
error.config = config; // Attach config to error
35+
error.response = {
36+
status: 429,
37+
headers: { "retry-after": "1" }, // 1 second
38+
config: config,
39+
data: {}
40+
};
41+
// We need to reject for the interceptor to catch it
42+
return Promise.reject(error);
43+
}
44+
return {
45+
status: 200,
46+
statusText: "OK",
47+
headers: {},
48+
config: config,
49+
data: { success: true }
50+
};
51+
};
52+
53+
// Override setTimeout to speed up tests
54+
const originalSetTimeout = global.setTimeout;
55+
global.setTimeout = (fn) => fn();
56+
57+
try {
58+
const response = await axios.get("https://auth0.com/api");
59+
expect(response.data.success).to.be.true;
60+
expect(attempts).to.equal(3); // Initial + 2 retries
61+
62+
// Verify logs
63+
const retryLogs = logSpy.filter(l => l.message.includes("Rate limited. Retrying"));
64+
expect(retryLogs.length).to.equal(2);
65+
} finally {
66+
global.setTimeout = originalSetTimeout;
67+
}
68+
});
69+
70+
it("should fail after max retries", async function() {
71+
let attempts = 0;
72+
73+
axios.defaults.adapter = async (config) => {
74+
attempts++;
75+
const error = new Error("Request failed with status code 429");
76+
error.config = config; // Attach config to error
77+
error.response = {
78+
status: 429,
79+
headers: {}, // No retry-after, use exponential backoff
80+
config: config,
81+
data: {}
82+
};
83+
return Promise.reject(error);
84+
};
85+
86+
// Override setTimeout
87+
const originalSetTimeout = global.setTimeout;
88+
global.setTimeout = (fn) => fn();
89+
90+
try {
91+
await axios.get("https://auth0.com/api");
92+
throw new Error("Should have failed");
93+
} catch (error) {
94+
expect(error.response.status).to.equal(429);
95+
// Initial + 5 retries = 6 attempts
96+
expect(attempts).to.equal(6);
97+
} finally {
98+
global.setTimeout = originalSetTimeout;
99+
}
100+
});
101+
102+
it("should respect Retry-After header", async function() {
103+
let attempts = 0;
104+
let delays = [];
105+
106+
// Mock setTimeout to capture delays
107+
const originalSetTimeout = global.setTimeout;
108+
global.setTimeout = (fn, delay) => {
109+
delays.push(delay);
110+
fn();
111+
};
112+
113+
axios.defaults.adapter = async (config) => {
114+
attempts++;
115+
if (attempts === 1) {
116+
const error = new Error("Request failed with status code 429");
117+
error.config = config; // Attach config to error
118+
error.response = {
119+
status: 429,
120+
headers: { "retry-after": "2" }, // 2 seconds
121+
config: config,
122+
data: {}
123+
};
124+
return Promise.reject(error);
125+
}
126+
return { status: 200, data: {} };
127+
};
128+
129+
try {
130+
await axios.get("https://auth0.com/api");
131+
expect(attempts).to.equal(2);
132+
// Delay should be around 2000ms + jitter
133+
expect(delays[0]).to.be.at.least(2000);
134+
expect(delays[0]).to.be.below(3100); // 2000 + max 1000 jitter
135+
} finally {
136+
global.setTimeout = originalSetTimeout;
137+
}
138+
});
139+
140+
it("should handle Date format Retry-After header", async function() {
141+
let attempts = 0;
142+
let delays = [];
143+
144+
// Mock setTimeout to capture delays
145+
const originalSetTimeout = global.setTimeout;
146+
global.setTimeout = (fn, delay) => {
147+
delays.push(delay);
148+
fn();
149+
};
150+
151+
axios.defaults.adapter = async (config) => {
152+
attempts++;
153+
if (attempts === 1) {
154+
const error = new Error("Request failed with status code 429");
155+
error.config = config; // Attach config to error
156+
error.response = {
157+
status: 429,
158+
headers: { "retry-after": "Wed, 21 Oct 2015 07:28:00 GMT" },
159+
config: config,
160+
data: {}
161+
};
162+
return Promise.reject(error);
163+
}
164+
return { status: 200, data: {} };
165+
};
166+
167+
try {
168+
await axios.get("https://auth0.com/api");
169+
expect(attempts).to.equal(2);
170+
// Delay should be default, around 1000ms + jitter
171+
expect(delays[0]).to.be.at.least(1000);
172+
expect(delays[0]).to.be.below(2000); // 1000 + max 1000 jitter
173+
} finally {
174+
global.setTimeout = originalSetTimeout;
175+
}
176+
});
177+
178+
it("should handle non existent Retry-After header", async function() {
179+
let attempts = 0;
180+
let delays = [];
181+
182+
// Mock setTimeout to capture delays
183+
const originalSetTimeout = global.setTimeout;
184+
global.setTimeout = (fn, delay) => {
185+
delays.push(delay);
186+
fn();
187+
};
188+
189+
axios.defaults.adapter = async (config) => {
190+
attempts++;
191+
if (attempts === 1) {
192+
const error = new Error("Request failed with status code 429");
193+
error.config = config; // Attach config to error
194+
error.response = {
195+
status: 429,
196+
headers: {}, // No retry-after
197+
config: config,
198+
data: {}
199+
};
200+
return Promise.reject(error);
201+
}
202+
return { status: 200, data: {} };
203+
};
204+
205+
try {
206+
await axios.get("https://auth0.com/api");
207+
expect(attempts).to.equal(2);
208+
// Delay should be default, around 1000ms + jitter
209+
expect(delays[0]).to.be.at.least(1000);
210+
expect(delays[0]).to.be.below(2000); // 1000 + max 1000 jitter
211+
} finally {
212+
global.setTimeout = originalSetTimeout;
213+
}
214+
});
215+
});

0 commit comments

Comments
 (0)