Skip to content

Commit 8523423

Browse files
authored
feat(confluence): API retries with exponential backoff (#262)
This PR introduces retries with exponential backoff to the Confluence API.
1 parent 15eab70 commit 8523423

File tree

8 files changed

+120
-21
lines changed

8 files changed

+120
-21
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@philips-software/backstage-plugin-search-confluence-backend': minor
3+
---
4+
5+
Added API retries with exponential backoff

workspaces/confluence/plugins/search-confluence-backend/README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,17 @@ confluence:
2929
auth:
3030
token: ${Your PAT Token}
3131
category:
32-
# provide the list of categories you want to use to find spaces that will be indexed
33-
# example
34-
# - space1
35-
# - space2
32+
# provide the list of categories you want to use to find spaces that will be indexed
33+
# example
34+
# - space1
35+
# - space2
36+
retries:
37+
attempts: 3 # optional, defaults to 3
38+
delay: 5000 # optional, defaults to 5000
3639
```
3740
41+
> Note: if the Confluence API rate limit is exceeded, the plugin will automatically retry with increasing wait times for a specified number of retry attempts. This can increase the duration of the task. Take this into account when setting the `schedule.frequency` and `schedule.timeout` values.
42+
3843
## Backend Configuration (Follows new backend)
3944

4045
Add the collator to your backend instance, along with the search plugin itself

workspaces/confluence/plugins/search-confluence-backend/config.d.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,24 @@ export interface Config {
4646
* @visibility backend
4747
*/
4848
category: string[];
49+
50+
/**
51+
* @visibility backend
52+
*/
53+
retries?: {
54+
/**
55+
* Number of attempts to retry.
56+
* Defaults to 3.
57+
* @visibility backend
58+
*/
59+
attempts?: string;
60+
61+
/**
62+
* Minimum delay between retries in milliseconds.
63+
* Defaults to 5000.
64+
* @visibility backend
65+
*/
66+
delay?: string;
67+
};
4968
};
5069
}

workspaces/confluence/plugins/search-confluence-backend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@
3636
"@backstage/backend-plugin-api": "backstage:^",
3737
"@backstage/backend-tasks": "^0.6.1",
3838
"@backstage/config": "backstage:^",
39+
"@backstage/errors": "backstage:^",
3940
"@backstage/plugin-search-backend-node": "backstage:^",
4041
"@backstage/plugin-search-common": "backstage:^",
4142
"p-limit": "^3.1.0",
43+
"retry": "^0.13.1",
4244
"uuid": "^9.0.1",
4345
"winston": "^3.2.1"
4446
},
@@ -47,6 +49,7 @@
4749
"@backstage/plugin-auth-backend": "backstage:^",
4850
"@backstage/plugin-auth-backend-module-guest-provider": "backstage:^",
4951
"@backstage/types": "backstage:^",
52+
"@types/retry": "^0.12.5",
5053
"@types/supertest": "^6.0.2",
5154
"@types/uuid": "^9.0.8",
5255
"jest-fetch-mock": "^3.0.3",

workspaces/confluence/plugins/search-confluence-backend/src/search/ConfluenceCollatorFactory/ConflunceCollaterFactory.test.ts renamed to workspaces/confluence/plugins/search-confluence-backend/src/search/ConfluenceCollatorFactory/ConfluenceCollaterFactory.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ConfluenceCollatorFactory } from './ConfluenceCollatorFactory';
22
import { ConfigReader } from '@backstage/config';
33
import { getVoidLogger } from '@backstage/backend-common';
44
import fetch from 'jest-fetch-mock';
5+
import retry from 'retry';
56

67
const createConfig = new ConfigReader({
78
confluence: {
@@ -274,6 +275,28 @@ describe('Testing ConfluenceCollatorFactory', () => {
274275
).rejects.toThrow('Not Found');
275276
});
276277

278+
it('should retry for a rate limit error response', async () => {
279+
const retryMock = jest.fn();
280+
jest.spyOn(retry, 'operation').mockReturnValue({
281+
...retry.operation(),
282+
attempt: fn => fn(1),
283+
retry: retryMock,
284+
});
285+
286+
const confluenceCollatorFactory = ConfluenceCollatorFactory.fromConfig(
287+
createConfig,
288+
{ logger },
289+
);
290+
291+
fetch.mockResponseOnce('Too Many Requests', { status: 429 });
292+
293+
await expect(
294+
confluenceCollatorFactory.get('https://example.com'),
295+
).rejects.toThrow('Too Many Requests');
296+
297+
expect(retryMock).toHaveBeenCalled();
298+
});
299+
277300
it('should return Bearer token if token is present', () => {
278301
const confluenceCollatorFactory = ConfluenceCollatorFactory.fromConfig(
279302
createTokenConfig,

workspaces/confluence/plugins/search-confluence-backend/src/search/ConfluenceCollatorFactory/ConfluenceCollatorFactory.ts

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ import {
1212
IndexableAncestorRef,
1313
IndexableConfluenceDocument,
1414
ConfluenceInstanceConfig,
15+
ConfluenceRetryConfig,
1516
} from './types';
1617
import { v4 as uuidv4 } from 'uuid';
18+
import retry from 'retry';
19+
import { ResponseError } from '@backstage/errors';
1720

1821
type ConfluenceCollatorOptions = {
1922
logger: Logger;
@@ -27,6 +30,7 @@ type ConfluenceCollatorOptions = {
2730
token?: string;
2831
};
2932
category: string[];
33+
retries?: ConfluenceRetryConfig;
3034
};
3135

3236
export const confluenceDefaultSchedule = {
@@ -49,6 +53,7 @@ export class ConfluenceCollatorFactory implements DocumentCollatorFactory {
4953
private parallelismLimit: number;
5054
private wikiUrl: string;
5155
private auth: { username?: string; password?: string; token?: string };
56+
private retries?: ConfluenceRetryConfig;
5257
public category: string[];
5358
static fromConfig(
5459
config: Config,
@@ -73,6 +78,7 @@ export class ConfluenceCollatorFactory implements DocumentCollatorFactory {
7378
wikiUrl: confluenceOptions.wikiUrl,
7479
auth: auth,
7580
category: confluenceOptions.category,
81+
retries: confluenceOptions.retries,
7682
});
7783
}
7884

@@ -83,6 +89,7 @@ export class ConfluenceCollatorFactory implements DocumentCollatorFactory {
8389
this.wikiUrl = options.wikiUrl;
8490
this.auth = options.auth;
8591
this.category = options.category;
92+
this.retries = options.retries;
8693
}
8794

8895
async getCollator() {
@@ -282,24 +289,45 @@ export class ConfluenceCollatorFactory implements DocumentCollatorFactory {
282289

283290
async get<T = any>(requestUrl: string): Promise<T> {
284291
const Authorization = this.getAuthorizationHeader();
285-
const res = await fetch(requestUrl, {
286-
method: 'get',
287-
headers: {
288-
Authorization,
289-
},
290-
});
291292

292-
if (!res.ok) {
293-
this.logger.warn(
294-
'non-ok response from confluence',
295-
requestUrl,
296-
res.status,
297-
await res.text(),
298-
);
293+
return new Promise<T>((resolve, reject) => {
294+
const operationRetry = retry.operation({
295+
retries: this.retries?.attempts ?? 3,
296+
minTimeout: this.retries?.delay ?? 5000,
297+
});
299298

300-
throw new Error(`Request failed with ${res.status} ${res.statusText}`);
301-
}
299+
operationRetry.attempt(async attempt => {
300+
try {
301+
const res = await fetch(requestUrl, {
302+
method: 'get',
303+
headers: {
304+
Authorization,
305+
},
306+
});
307+
308+
if (!res.ok) {
309+
this.logger.warn(
310+
`non-ok response from confluence (attempt ${attempt})`,
311+
requestUrl,
312+
res.status,
313+
await res.text(),
314+
);
315+
316+
throw await ResponseError.fromResponse(res);
317+
}
318+
319+
resolve(await res.json());
320+
} catch (e) {
321+
if (
322+
e instanceof ResponseError &&
323+
e.statusCode === 429 &&
324+
operationRetry.retry(e)
325+
)
326+
return;
302327

303-
return await res.json();
328+
reject(e);
329+
}
330+
});
331+
});
304332
}
305333
}

workspaces/confluence/plugins/search-confluence-backend/src/search/ConfluenceCollatorFactory/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ export type ConfluenceInstanceConfig = {
44
wikiUrl: string;
55
auth: { username?: string; password?: string; token?: string };
66
category: string[];
7+
retries?: ConfluenceRetryConfig;
8+
};
9+
10+
export type ConfluenceRetryConfig = {
11+
attempts?: number;
12+
delay?: number;
713
};
814

915
export type ConfluenceDocumentMetadata = {

workspaces/confluence/yarn.lock

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3289,7 +3289,7 @@ __metadata:
32893289
languageName: node
32903290
linkType: hard
32913291

3292-
"@backstage/errors@npm:^1.2.4":
3292+
"@backstage/errors@backstage:1.32.3, @backstage/errors@npm:^1.2.4":
32933293
version: 1.2.4
32943294
resolution: "@backstage/errors@npm:1.2.4"
32953295
dependencies:
@@ -6791,16 +6791,19 @@ __metadata:
67916791
"@backstage/backend-tasks": "npm:^0.6.1"
67926792
"@backstage/cli": "backstage:^"
67936793
"@backstage/config": "backstage:^"
6794+
"@backstage/errors": "backstage:^"
67946795
"@backstage/plugin-auth-backend": "backstage:^"
67956796
"@backstage/plugin-auth-backend-module-guest-provider": "backstage:^"
67966797
"@backstage/plugin-search-backend-node": "backstage:^"
67976798
"@backstage/plugin-search-common": "backstage:^"
67986799
"@backstage/types": "backstage:^"
6800+
"@types/retry": "npm:^0.12.5"
67996801
"@types/supertest": "npm:^6.0.2"
68006802
"@types/uuid": "npm:^9.0.8"
68016803
jest-fetch-mock: "npm:^3.0.3"
68026804
msw: "npm:^1.0.0"
68036805
p-limit: "npm:^3.1.0"
6806+
retry: "npm:^0.13.1"
68046807
uuid: "npm:^9.0.1"
68056808
winston: "npm:^3.2.1"
68066809
languageName: unknown
@@ -9273,6 +9276,13 @@ __metadata:
92739276
languageName: node
92749277
linkType: hard
92759278

9279+
"@types/retry@npm:^0.12.5":
9280+
version: 0.12.5
9281+
resolution: "@types/retry@npm:0.12.5"
9282+
checksum: 10/3fb6bf91835ca0eb2987567d6977585235a7567f8aeb38b34a8bb7bbee57ac050ed6f04b9998cda29701b8c893f5dfe315869bc54ac17e536c9235637fe351a2
9283+
languageName: node
9284+
linkType: hard
9285+
92769286
"@types/sarif@npm:^2.1.4":
92779287
version: 2.1.7
92789288
resolution: "@types/sarif@npm:2.1.7"

0 commit comments

Comments
 (0)