diff --git a/lib/confluence-client.js b/lib/confluence-client.js index 2ac73f1..9b15af3 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -11,6 +11,8 @@ const { htmlToMarkdown, NAMED_ENTITIES } = require('./html-to-markdown'); const WRITE_FORMATS = ['auto', 'storage', 'html', 'markdown']; const PAGE_LINK_LOOKUP_CONCURRENCY = 10; +const RETRY_SAFE_METHODS = new Set(['get', 'head', 'options']); +const MAX_RETRY_DELAY_MS = 60000; const escapeXmlText = (value) => String(value) .replace(/&/g, '&') @@ -132,9 +134,32 @@ class ConfluenceClient { this.client = axios.create(clientOptions); + this.maxRetries = 3; + this.retryBaseDelayMs = 1000; + this.client.interceptors.response.use( response => response, - error => { + async error => { + const requestConfig = error.config; + const status = error.response?.status; + // 429 means the server rejected the request before processing, so + // any method is safe to replay. A 503 can arrive after a proxy or + // backend already applied a write, so only retry read methods. + const retryable = status === 429 || + (status === 503 && RETRY_SAFE_METHODS.has((requestConfig?.method || '').toLowerCase())); + if ( + requestConfig && + retryable && + // A stream body is consumed on first send and cannot be replayed + typeof requestConfig.data?.pipe !== 'function' + ) { + const attempt = requestConfig.__retryCount || 0; + if (attempt < this.maxRetries) { + requestConfig.__retryCount = attempt + 1; + await this.sleep(this.retryDelayMs(error.response, attempt)); + return this.client.request(requestConfig); + } + } if (error.response?.status === 401) { const hints = ['Authentication failed (401 Unauthorized).']; if (this.isScopedToken()) { @@ -562,26 +587,14 @@ class ConfluenceClient { const cap = maxResults === null || maxResults === undefined ? null : this.parsePositiveInt(maxResults, 500); - const pageSize = this.parsePositiveInt(options.pageSize, 500); - let start = this.parsePositiveInt(options.start, 0); - const spaces = []; - - let hasNext = true; - while (hasNext) { - if (cap !== null && spaces.length >= cap) break; - const requestLimit = cap === null - ? pageSize - : Math.min(pageSize, cap - spaces.length); - const page = await this.listSpaces({ limit: requestLimit, start }); - spaces.push(...page.results); - - hasNext = page.nextStart !== null && page.nextStart !== undefined; - if (hasNext) { - start = page.nextStart; + return this.collectPaginated( + ({ limit, start }) => this.listSpaces({ limit, start }), + { + maxResults: cap, + pageSize: this.parsePositiveInt(options.pageSize, 500), + start: this.parsePositiveInt(options.start, 0) } - } - - return cap !== null ? spaces.slice(0, cap) : spaces; + ); } /** @@ -895,34 +908,21 @@ class ConfluenceClient { * Fetch all comments for a page, honoring an optional maxResults cap */ async getAllComments(pageIdOrUrl, options = {}) { - const pageSize = this.parsePositiveInt(options.pageSize || options.limit, 25); - const maxResults = this.parsePositiveInt(options.maxResults, null); - let start = this.parsePositiveInt(options.start, 0); - const comments = []; - - let hasNext = true; - while (hasNext) { - const page = await this.listComments(pageIdOrUrl, { - limit: pageSize, + return this.collectPaginated( + ({ limit, start }) => this.listComments(pageIdOrUrl, { + limit, start, expand: options.expand, location: options.location, depth: options.depth, parentVersion: options.parentVersion - }); - comments.push(...page.results); - - if (maxResults && comments.length >= maxResults) { - return comments.slice(0, maxResults); - } - - hasNext = page.nextStart !== null && page.nextStart !== undefined; - if (hasNext) { - start = page.nextStart; + }), + { + maxResults: this.parsePositiveInt(options.maxResults, null) || null, + pageSize: this.parsePositiveInt(options.pageSize || options.limit, 25), + start: this.parsePositiveInt(options.start, 0) } - } - - return comments; + ); } normalizeComment(raw) { @@ -1111,32 +1111,18 @@ class ConfluenceClient { * Fetch all attachments for a page, honoring an optional maxResults cap */ async getAllAttachments(pageIdOrUrl, options = {}) { - const pageSize = this.parsePositiveInt(options.pageSize || options.limit, 50); - const maxResults = this.parsePositiveInt(options.maxResults, null); - const filename = options.filename; - let start = this.parsePositiveInt(options.start, 0); - const attachments = []; - - let hasNext = true; - while (hasNext) { - const page = await this.listAttachments(pageIdOrUrl, { - limit: pageSize, + return this.collectPaginated( + ({ limit, start }) => this.listAttachments(pageIdOrUrl, { + limit, start, - filename - }); - attachments.push(...page.results); - - if (maxResults && attachments.length >= maxResults) { - return attachments.slice(0, maxResults); - } - - hasNext = page.nextStart !== null && page.nextStart !== undefined; - if (hasNext) { - start = page.nextStart; + filename: options.filename + }), + { + maxResults: this.parsePositiveInt(options.maxResults, null) || null, + pageSize: this.parsePositiveInt(options.pageSize || options.limit, 50), + start: this.parsePositiveInt(options.start, 0) } - } - - return attachments; + ); } /** @@ -1287,30 +1273,14 @@ class ConfluenceClient { * Fetch all content properties for a page, honoring an optional maxResults cap */ async getAllProperties(pageIdOrUrl, options = {}) { - const pageSize = this.parsePositiveInt(options.pageSize || options.limit, 25); - const maxResults = this.parsePositiveInt(options.maxResults, null); - let start = this.parsePositiveInt(options.start, 0); - const properties = []; - - let hasNext = true; - while (hasNext) { - const page = await this.listProperties(pageIdOrUrl, { - limit: pageSize, - start - }); - properties.push(...page.results); - - if (maxResults && properties.length >= maxResults) { - return properties.slice(0, maxResults); - } - - hasNext = page.nextStart !== null && page.nextStart !== undefined; - if (hasNext) { - start = page.nextStart; + return this.collectPaginated( + ({ limit, start }) => this.listProperties(pageIdOrUrl, { limit, start }), + { + maxResults: this.parsePositiveInt(options.maxResults, null) || null, + pageSize: this.parsePositiveInt(options.pageSize || options.limit, 25), + start: this.parsePositiveInt(options.start, 0) } - } - - return properties; + ); } /** @@ -2228,6 +2198,53 @@ class ConfluenceClient { return parsed; } + sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Delay before retrying a throttled request: honor Retry-After + * (seconds or HTTP-date), otherwise exponential backoff. + */ + retryDelayMs(response, attempt) { + const retryAfter = response?.headers?.['retry-after']; + if (retryAfter !== undefined && retryAfter !== null && retryAfter !== '') { + const seconds = Number(retryAfter); + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.min(seconds * 1000, MAX_RETRY_DELAY_MS); + } + const dateMs = Date.parse(retryAfter); + if (!Number.isNaN(dateMs)) { + return Math.min(Math.max(dateMs - Date.now(), 0), MAX_RETRY_DELAY_MS); + } + } + return Math.min(this.retryBaseDelayMs * 2 ** attempt, MAX_RETRY_DELAY_MS); + } + + /** + * Accumulate results across start/limit pages until the server stops + * returning a next link or `maxResults` is reached (null = unlimited). + * `fetchPage({ limit, start })` must resolve to `{ results, nextStart }`. + */ + async collectPaginated(fetchPage, { maxResults = null, pageSize, start = 0 }) { + const items = []; + let hasNext = true; + while (hasNext) { + if (maxResults !== null && items.length >= maxResults) break; + const limit = maxResults === null + ? pageSize + : Math.min(pageSize, maxResults - items.length); + const page = await fetchPage({ limit, start }); + items.push(...page.results); + + hasNext = page.nextStart !== null && page.nextStart !== undefined; + if (hasNext) { + start = page.nextStart; + } + } + return maxResults !== null ? items.slice(0, maxResults) : items; + } + async rawRequest(method, endpoint, options = {}) { const { headers = {}, params, data } = options; diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index fad9200..a60ae21 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -349,6 +349,123 @@ describe('ConfluenceClient', () => { }); }); + describe('rate-limit retry (429/503)', () => { + const makeClient = () => { + const retryClient = new ConfluenceClient({ + domain: 'confluence.company.com', + token: 'test-token', + apiPath: '/rest/api' + }); + retryClient.retryBaseDelayMs = 1; + return retryClient; + }; + + test('retries a 429 response and succeeds', async () => { + const retryClient = makeClient(); + const mock = new MockAdapter(retryClient.client); + mock + .onGet(/\/content\/123/).replyOnce(429, {}, { 'retry-after': '0' }) + .onGet(/\/content\/123/).replyOnce(200, { + body: { storage: { value: '
hi
' } } + }); + + await expect(retryClient.readPage('123', 'storage')).resolves.toBe('hi
'); + expect(mock.history.get.length).toBe(2); + mock.restore(); + }); + + test('retries a 503 response and succeeds', async () => { + const retryClient = makeClient(); + const mock = new MockAdapter(retryClient.client); + mock + .onGet(/\/content\/123/).replyOnce(503) + .onGet(/\/content\/123/).replyOnce(200, { + body: { storage: { value: 'hi
' } } + }); + + await expect(retryClient.readPage('123', 'storage')).resolves.toBe('hi
'); + expect(mock.history.get.length).toBe(2); + mock.restore(); + }); + + test('retries a 429 write request (rejected before processing)', async () => { + const retryClient = makeClient(); + const mock = new MockAdapter(retryClient.client); + mock + .onPost('/content').replyOnce(429, {}, { 'retry-after': '0' }) + .onPost('/content').replyOnce(200, { id: '456' }); + + const response = await retryClient.client.post('/content', { title: 'New page' }); + expect(response.data.id).toBe('456'); + expect(mock.history.post.length).toBe(2); + mock.restore(); + }); + + test('does not retry a 503 write request (may already be applied)', async () => { + const retryClient = makeClient(); + const mock = new MockAdapter(retryClient.client); + mock.onPost('/content').reply(503); + + await expect( + retryClient.client.post('/content', { title: 'New page' }) + ).rejects.toThrow(/503/); + expect(mock.history.post.length).toBe(1); + mock.restore(); + }); + + test('gives up after maxRetries attempts', async () => { + const retryClient = makeClient(); + retryClient.maxRetries = 2; + const mock = new MockAdapter(retryClient.client); + mock.onGet(/\/content\/123/).reply(429); + + await expect(retryClient.readPage('123')).rejects.toThrow(/429/); + expect(mock.history.get.length).toBe(3); + mock.restore(); + }); + + test('does not retry non-retryable errors', async () => { + const retryClient = makeClient(); + const mock = new MockAdapter(retryClient.client); + mock.onGet(/\/content\/123/).reply(400); + + await expect(retryClient.readPage('123')).rejects.toThrow(/400/); + expect(mock.history.get.length).toBe(1); + mock.restore(); + }); + + test('does not retry requests with a stream body', async () => { + const retryClient = makeClient(); + const mock = new MockAdapter(retryClient.client); + mock.onPost('/upload').reply(429); + + const streamLike = { pipe: () => {} }; + await expect( + retryClient.client.request({ url: '/upload', method: 'post', data: streamLike }) + ).rejects.toThrow(/429/); + expect(mock.history.post.length).toBe(1); + mock.restore(); + }); + + test('retryDelayMs honors Retry-After seconds, dates, and backoff', () => { + const retryClient = makeClient(); + retryClient.retryBaseDelayMs = 1000; + + expect(retryClient.retryDelayMs({ headers: { 'retry-after': '2' } }, 0)).toBe(2000); + expect(retryClient.retryDelayMs({ headers: { 'retry-after': '120' } }, 0)).toBe(60000); + + const soon = new Date(Date.now() + 5000).toUTCString(); + const dateDelay = retryClient.retryDelayMs({ headers: { 'retry-after': soon } }, 0); + expect(dateDelay).toBeGreaterThan(0); + expect(dateDelay).toBeLessThanOrEqual(5000); + + expect(retryClient.retryDelayMs({ headers: {} }, 0)).toBe(1000); + expect(retryClient.retryDelayMs({ headers: {} }, 1)).toBe(2000); + expect(retryClient.retryDelayMs({ headers: {} }, 2)).toBe(4000); + expect(retryClient.retryDelayMs(undefined, 0)).toBe(1000); + }); + }); + describe('401 error handling (cookie auth)', () => { test('provides cookie-specific hints for cookie auth', async () => { const cookieClient = new ConfluenceClient({