Skip to content

Commit 67a93c7

Browse files
authored
Merge pull request #382 from johanneswuerbach/fix-error-handling
fix(humanitec): continue polling on error
2 parents 1bb3371 + b9e57e3 commit 67a93c7

File tree

4 files changed

+156
-28
lines changed

4 files changed

+156
-28
lines changed

.changeset/green-coats-march.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@frontside/backstage-plugin-humanitec-backend': patch
3+
---
4+
5+
Continue polling when an error is returned.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { setTimeout } from 'node:timers/promises';
2+
import * as common from '@frontside/backstage-plugin-humanitec-common';
3+
4+
import { AppInfoService } from './app-info-service';
5+
6+
const fetchInterval = 50;
7+
8+
let returnError = false;
9+
const fakeAppInfo = { fake: 'res' }
10+
const fakeError = new Error('fake error');
11+
12+
jest.mock('@frontside/backstage-plugin-humanitec-common', () => ({
13+
createHumanitecClient: jest.fn(),
14+
fetchAppInfo: jest.fn(async () => {
15+
if (returnError) {
16+
throw fakeError;
17+
}
18+
19+
return fakeAppInfo;
20+
}),
21+
}))
22+
23+
describe('AppInfoService', () => {
24+
afterEach(() => {
25+
jest.clearAllMocks();
26+
returnError = false;
27+
});
28+
29+
it('single subscriber', async () => {
30+
const service = new AppInfoService('token', fetchInterval);
31+
const subscriber = jest.fn();
32+
33+
const close = service.addSubscriber('orgId', 'appId', subscriber);
34+
35+
await setTimeout(50);
36+
37+
expect(subscriber).toHaveBeenCalledTimes(1);
38+
expect(subscriber).toHaveBeenLastCalledWith({ id: 0, data: fakeAppInfo });
39+
expect(common.createHumanitecClient).toHaveBeenCalledTimes(1);
40+
41+
await setTimeout(fetchInterval);
42+
43+
expect(subscriber).toHaveBeenCalledTimes(2);
44+
expect(subscriber).toHaveBeenLastCalledWith({ id: 1, data: fakeAppInfo });
45+
expect(common.createHumanitecClient).toHaveBeenCalledTimes(2);
46+
47+
close();
48+
49+
await setTimeout(fetchInterval * 2);
50+
51+
expect(subscriber).toHaveBeenCalledTimes(2);
52+
expect(common.createHumanitecClient).toHaveBeenCalledTimes(2);
53+
});
54+
55+
it('single subscriber, recovers after an erro', async () => {
56+
returnError = true
57+
58+
const service = new AppInfoService('token', fetchInterval);
59+
const subscriber = jest.fn();
60+
61+
const close = service.addSubscriber('orgId', 'appId', subscriber);
62+
63+
await setTimeout(50);
64+
65+
expect(subscriber).toHaveBeenCalledTimes(1);
66+
expect(subscriber).toHaveBeenLastCalledWith({ id: 0, error: fakeError });
67+
expect(common.createHumanitecClient).toHaveBeenCalledTimes(1);
68+
69+
returnError = false;
70+
71+
await setTimeout(fetchInterval);
72+
73+
expect(subscriber).toHaveBeenCalledTimes(2);
74+
expect(subscriber).toHaveBeenLastCalledWith({ id: 1, data: fakeAppInfo });
75+
expect(common.createHumanitecClient).toHaveBeenCalledTimes(2);
76+
77+
close();
78+
79+
await setTimeout(fetchInterval * 2);
80+
81+
expect(subscriber).toHaveBeenCalledTimes(2);
82+
expect(common.createHumanitecClient).toHaveBeenCalledTimes(2);
83+
});
84+
85+
it('two subscribers', async () => {
86+
const service = new AppInfoService('token', fetchInterval);
87+
const subscriber1 = jest.fn();
88+
const subscriber2 = jest.fn();
89+
90+
const close1 = service.addSubscriber('orgId', 'appId', subscriber1);
91+
const close2 = service.addSubscriber('orgId', 'appId', subscriber2);
92+
93+
await setTimeout(10);
94+
95+
expect(subscriber1).toHaveBeenCalledTimes(1);
96+
expect(subscriber2).toHaveBeenCalledTimes(1);
97+
expect(subscriber1).toHaveBeenLastCalledWith({ id: 0, data: fakeAppInfo });
98+
expect(subscriber2).toHaveBeenLastCalledWith({ id: 0, data: fakeAppInfo });
99+
expect(common.createHumanitecClient).toHaveBeenCalledTimes(1);
100+
101+
await setTimeout(fetchInterval);
102+
103+
expect(subscriber1).toHaveBeenCalledTimes(2);
104+
expect(subscriber1).toHaveBeenLastCalledWith({ id: 1, data: fakeAppInfo });
105+
expect(subscriber2).toHaveBeenCalledTimes(2);
106+
expect(subscriber2).toHaveBeenLastCalledWith({ id: 1, data: fakeAppInfo });
107+
expect(common.createHumanitecClient).toHaveBeenCalledTimes(2);
108+
109+
close1();
110+
111+
await setTimeout(fetchInterval);
112+
113+
expect(subscriber1).toHaveBeenCalledTimes(2);
114+
expect(subscriber2).toHaveBeenCalledTimes(3);
115+
expect(subscriber2).toHaveBeenLastCalledWith({ id: 2, data: fakeAppInfo });
116+
expect(common.createHumanitecClient).toHaveBeenCalledTimes(3);
117+
118+
close2();
119+
120+
await setTimeout(fetchInterval);
121+
122+
expect(subscriber1).toHaveBeenCalledTimes(2);
123+
expect(subscriber2).toHaveBeenCalledTimes(3);
124+
expect(common.createHumanitecClient).toHaveBeenCalledTimes(3);
125+
});
126+
});

plugins/humanitec-backend/src/service/app-info-service.ts

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { EventEmitter } from 'events';
22

33
import { createHumanitecClient, fetchAppInfo } from '@frontside/backstage-plugin-humanitec-common';
44

5-
const fetchInterval = 10000;
5+
const defaultFetchInterval = 10000;
66

77
export interface AppInfoUpdate {
88
id: number;
@@ -16,14 +16,16 @@ export interface AppInfoUpdate {
1616
//
1717
export class AppInfoService {
1818
private emitter: EventEmitter = new EventEmitter();
19-
private pending: Record<string, Promise<any>> = {};
20-
private timeouts: Record<string, NodeJS.Timeout> = {};
21-
private lastData: Record<string, AppInfoUpdate> = {};
19+
private pending: Map<string, Promise<any>> = new Map();
20+
private timeouts: Map<string, NodeJS.Timeout> = new Map();
21+
private lastData: Map<string, AppInfoUpdate> = new Map();
2222

2323
private token: string;
24+
private fetchInterval: number;
2425

25-
constructor(token: string) {
26+
constructor(token: string, fetchInterval = defaultFetchInterval) {
2627
this.token = token;
28+
this.fetchInterval = fetchInterval;
2729
}
2830

2931
addSubscriber(orgId: string, appId: string, subscriber: (data: AppInfoUpdate) => void): () => void {
@@ -32,49 +34,48 @@ export class AppInfoService {
3234
this.emitter.on(key, subscriber);
3335

3436
// Only fetch app info if a fetch is not pending.
35-
if (!this.pending[key]) {
37+
if (!this.pending.has(key)) {
3638
this.fetchAppInfo(orgId, appId);
3739
} else {
38-
if (this.lastData[key]) {
39-
subscriber(this.lastData[key]);
40+
if (this.lastData.has(key)) {
41+
subscriber(this.lastData.get(key)!);
4042
}
4143
}
4244

4345
// Return a function that removes this subscriber when it's no longer interested.
4446
return () => {
4547
this.emitter.off(key, subscriber);
46-
if (this.emitter.listenerCount(key) === 0 && this.timeouts[key]) {
47-
clearTimeout(this.timeouts[key]);
48-
delete this.pending[key];
49-
delete this.timeouts[key];
50-
delete this.lastData[key];
48+
if (this.emitter.listenerCount(key) === 0 && this.timeouts.has(key)) {
49+
clearTimeout(this.timeouts.get(key)!);
50+
this.timeouts.delete(key);
51+
this.pending.delete(key);
52+
this.lastData.delete(key);
5153
}
5254
};
5355
}
5456

55-
private fetchAppInfo(orgId: string, appId: string): Promise<any> {
57+
private fetchAppInfo(orgId: string, appId: string): void {
5658
const key = `${orgId}:${appId}`;
5759
const client = createHumanitecClient({ token: this.token, orgId });
58-
let id = 0;
5960

60-
this.pending[key] = (async () => {
61-
const update: AppInfoUpdate = { id: id++ };
61+
const id = this.lastData.has(key) ? this.lastData.get(key)!.id + 1 : 0;
62+
63+
this.pending.set(key, (async () => {
64+
const update: AppInfoUpdate = { id: id };
6265
try {
6366
const data = await fetchAppInfo({ client }, appId);
6467
update.data = data;
65-
66-
this.timeouts[key] = setTimeout(()=> this.fetchAppInfo(orgId, appId), fetchInterval);
6768
} catch (error) {
6869
if (error instanceof Error) {
6970
update.error = error;
7071
} else {
7172
update.error = new Error(`${error}`);
7273
}
7374
} finally {
75+
this.timeouts.set(key, setTimeout(() => this.fetchAppInfo(orgId, appId), this.fetchInterval));
76+
this.lastData.set(key, update);
7477
this.emitter.emit(key, update);
75-
this.lastData[key] = update;
7678
}
77-
})();
78-
return this.pending[key];
79+
})());
7980
}
8081
}

plugins/humanitec-backend/src/service/router.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,11 @@ export async function createRouter(
6161
const unsubscribe = appInfoService.addSubscriber(orgId, appId, (data) => {
6262
if (data.error) {
6363
response.write(`event: update-failure\ndata: ${data.error.message}\nid: ${data.id}\n\n`);
64-
flush(response);
6564
logger.error(`Error encountered trying to update environment`, data.error);
66-
response.end();
67-
unsubscribe();
68-
69-
return
65+
} else {
66+
response.write(`event: update-success\ndata: ${JSON.stringify(data.data)}\nid: ${data.id}\n\n`);
7067
}
7168

72-
response.write(`event: update-success\ndata: ${JSON.stringify(data.data)}\nid: ${data.id}\n\n`);
7369
flush(response);
7470
});
7571

0 commit comments

Comments
 (0)