Skip to content

Commit 152dbf1

Browse files
committed
Merge fix/issue-5-error-handling: error details + rate limit retry (closes #5)
* fix/issue-5-error-handling: feat: improved error handling + rate limit retry (closes #5)
2 parents 9006ade + f2c575f commit 152dbf1

4 files changed

Lines changed: 205 additions & 3 deletions

File tree

bin/notion.js

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,35 @@ async function resolvePageId(aliasOrId, filterInput) {
137137
// ─── Lazy Notion client ────────────────────────────────────────────────────────
138138

139139
let _notion = null;
140+
let _notionWithRetry = null;
141+
142+
function wrapNotionClient(notion) {
143+
const wrap = target => new Proxy(target, {
144+
get(obj, prop) {
145+
const value = obj[prop];
146+
if (typeof value === 'function') {
147+
return (...args) => withRetry(() => value.apply(obj, args));
148+
}
149+
if (value && typeof value === 'object') {
150+
return wrap(value);
151+
}
152+
return value;
153+
},
154+
});
155+
return wrap(notion);
156+
}
157+
158+
function createNotionClient(apiKey) {
159+
const notion = new Client({ auth: apiKey });
160+
return wrapNotionClient(notion);
161+
}
162+
140163
function getNotion() {
141164
if (!_notion) {
142165
_notion = new Client({ auth: getApiKey() });
166+
_notionWithRetry = wrapNotionClient(_notion);
143167
}
144-
return _notion;
168+
return _notionWithRetry;
145169
}
146170

147171
// ─── Helpers (imported from lib/helpers.js) ────────────────────────────────────
@@ -164,6 +188,8 @@ const {
164188
extractDynamicProps,
165189
UUID_REGEX,
166190
paginate,
191+
withRetry,
192+
getNotionApiErrorDetails,
167193
} = helpers;
168194

169195
/** Check if --json flag is set anywhere in the command chain */
@@ -184,7 +210,21 @@ async function runCommand(name, fn) {
184210
try {
185211
await fn();
186212
} catch (err) {
187-
console.error(`${name} failed:`, err.message);
213+
const details = getNotionApiErrorDetails(err);
214+
if (details) {
215+
console.error(`${name} failed: Notion API error`);
216+
if (details.status !== undefined) console.error(`Status: ${details.status}`);
217+
if (details.code) console.error(`Code: ${details.code}`);
218+
if (details.message) console.error(`Message: ${details.message}`);
219+
if (details.body) {
220+
const bodyText = typeof details.body === 'string'
221+
? details.body
222+
: JSON.stringify(details.body, null, 2);
223+
console.error(`Body: ${bodyText}`);
224+
}
225+
} else {
226+
console.error(`${name} failed:`, err.message);
227+
}
188228
process.exit(1);
189229
}
190230
}
@@ -300,7 +340,7 @@ program
300340
console.log('');
301341

302342
// Discover databases
303-
const notion = new Client({ auth: apiKey });
343+
const notion = createNotionClient(apiKey);
304344
try {
305345
const res = await notion.search({
306346
filter: { value: 'data_source', property: 'object' },

lib/helpers.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ const filters = require('./filters');
44
const format = require('./format');
55
const markdown = require('./markdown');
66
const paginate = require('./paginate');
7+
const retry = require('./retry');
78

89
module.exports = {
910
...config,
1011
...filters,
1112
...format,
1213
...markdown,
1314
...paginate,
15+
...retry,
1416
};

lib/retry.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// lib/retry.js — Retry helpers for Notion API calls
2+
3+
function sleep(ms) {
4+
return new Promise(resolve => setTimeout(resolve, ms));
5+
}
6+
7+
function isRateLimitError(err) {
8+
if (!err || typeof err !== 'object') return false;
9+
return err.status === 429 || err.code === 'rate_limited';
10+
}
11+
12+
function isNotionApiError(err) {
13+
if (!err || typeof err !== 'object') return false;
14+
if (err.name === 'APIResponseError') return true;
15+
return typeof err.status === 'number' && err.body && typeof err.body === 'object';
16+
}
17+
18+
function getNotionApiErrorDetails(err) {
19+
if (!isNotionApiError(err)) return null;
20+
const details = {
21+
status: err.status,
22+
code: err.code,
23+
body: err.body,
24+
};
25+
if (err.message && (!err.body || err.body.message !== err.message)) {
26+
details.message = err.message;
27+
}
28+
return details;
29+
}
30+
31+
function calculateDelayMs(baseDelayMs, attempt, jitter, randomFn) {
32+
const delayMs = baseDelayMs * (2 ** (attempt - 1));
33+
if (!jitter) return delayMs;
34+
const rand = typeof randomFn === 'function' ? randomFn() : Math.random();
35+
const jittered = delayMs * (0.5 + rand);
36+
return Math.max(0, Math.floor(jittered));
37+
}
38+
39+
async function withRetry(fn, options = {}) {
40+
const {
41+
maxAttempts = 3,
42+
baseDelayMs = 1000,
43+
jitter = true,
44+
random = Math.random,
45+
sleep: sleepFn = sleep,
46+
onRetry,
47+
} = options;
48+
49+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
50+
try {
51+
return await fn();
52+
} catch (err) {
53+
if (!isRateLimitError(err) || attempt === maxAttempts) {
54+
throw err;
55+
}
56+
const delayMs = calculateDelayMs(baseDelayMs, attempt, jitter, random);
57+
if (typeof onRetry === 'function') {
58+
onRetry({ attempt, maxAttempts, delayMs, error: err });
59+
} else {
60+
const delaySec = Math.max(0.1, Math.round(delayMs / 100) / 10);
61+
console.error(`Rate limited, retrying in ${delaySec}s...`);
62+
}
63+
await sleepFn(delayMs);
64+
}
65+
}
66+
67+
return undefined;
68+
}
69+
70+
module.exports = {
71+
isRateLimitError,
72+
isNotionApiError,
73+
getNotionApiErrorDetails,
74+
withRetry,
75+
calculateDelayMs,
76+
};

test/unit.test.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const {
2222
extractDynamicProps,
2323
UUID_REGEX,
2424
paginate,
25+
withRetry,
2526
} = require('../lib/helpers');
2627

2728
// ─── richTextToPlain ───────────────────────────────────────────────────────────
@@ -1146,3 +1147,86 @@ describe('paginate', () => {
11461147
assert.equal(has_more, false);
11471148
});
11481149
});
1150+
1151+
// ─── withRetry ────────────────────────────────────────────────────────────────
1152+
1153+
describe('withRetry', () => {
1154+
function rateLimitError() {
1155+
const err = new Error('Rate limited');
1156+
err.status = 429;
1157+
err.code = 'rate_limited';
1158+
err.body = { message: 'Rate limited' };
1159+
return err;
1160+
}
1161+
1162+
it('retries rate limit errors and eventually succeeds', async () => {
1163+
let calls = 0;
1164+
const delays = [];
1165+
const fn = async () => {
1166+
calls += 1;
1167+
if (calls < 3) {
1168+
throw rateLimitError();
1169+
}
1170+
return 'ok';
1171+
};
1172+
1173+
const result = await withRetry(fn, {
1174+
maxAttempts: 3,
1175+
baseDelayMs: 1000,
1176+
jitter: false,
1177+
sleep: async (ms) => {
1178+
delays.push(ms);
1179+
},
1180+
onRetry: () => {},
1181+
});
1182+
1183+
assert.equal(result, 'ok');
1184+
assert.equal(calls, 3);
1185+
assert.deepEqual(delays, [1000, 2000]);
1186+
});
1187+
1188+
it('stops after max attempts on repeated rate limits', async () => {
1189+
let calls = 0;
1190+
const fn = async () => {
1191+
calls += 1;
1192+
throw rateLimitError();
1193+
};
1194+
1195+
await assert.rejects(
1196+
() => withRetry(fn, {
1197+
maxAttempts: 3,
1198+
baseDelayMs: 10,
1199+
jitter: false,
1200+
sleep: async () => {},
1201+
onRetry: () => {},
1202+
}),
1203+
/Rate limited/,
1204+
);
1205+
1206+
assert.equal(calls, 3);
1207+
});
1208+
1209+
it('does not retry non-rate-limit errors', async () => {
1210+
let calls = 0;
1211+
const err = new Error('Boom');
1212+
err.status = 400;
1213+
err.code = 'invalid_request';
1214+
err.body = { message: 'Boom' };
1215+
1216+
await assert.rejects(
1217+
() => withRetry(async () => {
1218+
calls += 1;
1219+
throw err;
1220+
}, {
1221+
maxAttempts: 3,
1222+
baseDelayMs: 10,
1223+
jitter: false,
1224+
sleep: async () => {},
1225+
onRetry: () => {},
1226+
}),
1227+
/Boom/,
1228+
);
1229+
1230+
assert.equal(calls, 1);
1231+
});
1232+
});

0 commit comments

Comments
 (0)