Skip to content

Commit d86b815

Browse files
ambirdsalldanidelvalle-frontiers
authored andcommitted
🪟 🐛 Poll backend for Free Connector Program enrollment success (airbytehq#22289)
* Add pollUntil utility for polling Promises * poll backend for confirmed enrollment before showing success toast * Put interval and maxTimeout inside options arg * Give units w/ polling options: intervalMs and maxTimeoutMs
1 parent dc835e3 commit d86b815

File tree

4 files changed

+112
-6
lines changed

4 files changed

+112
-6
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { pollUntil } from "./pollUntil";
2+
3+
// a toy promise that can be polled for a specific response
4+
const fourZerosAndThenSeven = () => {
5+
let _callCount = 0;
6+
return () => Promise.resolve([0, 0, 0, 0, 7][_callCount++]);
7+
};
8+
// eslint-disable-next-line
9+
const truthyResponse = (x: any) => !!x;
10+
11+
describe("pollUntil", () => {
12+
describe("when maxTimeoutMs is not provided", () => {
13+
it("calls the provided apiFn until condition returns true and resolves to its final return value", () => {
14+
const pollableFn = fourZerosAndThenSeven();
15+
16+
return expect(pollUntil(pollableFn, truthyResponse, { intervalMs: 1 })).resolves.toBe(7);
17+
});
18+
});
19+
20+
describe("when condition returns true before maxTimeoutMs is reached", () => {
21+
it("calls the provided apiFn until condition returns true and resolves to its final return value", () => {
22+
const pollableFn = fourZerosAndThenSeven();
23+
24+
return expect(pollUntil(pollableFn, truthyResponse, { intervalMs: 1, maxTimeoutMs: 100 })).resolves.toBe(7);
25+
});
26+
});
27+
28+
describe("when maxTimeoutMs is reached before condition returns true", () => {
29+
it("resolves to false", () => {
30+
const pollableFn = fourZerosAndThenSeven();
31+
32+
return expect(pollUntil(pollableFn, truthyResponse, { intervalMs: 100, maxTimeoutMs: 1 })).resolves.toBe(false);
33+
});
34+
35+
// Because the timing of the polling depends on both the provided `intervalMs` and the
36+
// execution time of `apiFn`, the timing of polling iterations isn't entirely
37+
// deterministic; it's precise enough for its job, but it's difficult to make precise
38+
// test assertions about polling behavior without long intervalMs/maxTimeoutMs bogging
39+
// down the test suite.
40+
it("calls its apiFn arg no more than (maxTimeoutMs / intervalMs) times", async () => {
41+
let _callCount = 0;
42+
let lastCalledValue = 999;
43+
const pollableFn = () =>
44+
Promise.resolve([1, 2, 3, 4, 5][_callCount++]).then((val) => {
45+
lastCalledValue = val;
46+
return val;
47+
});
48+
49+
await pollUntil(pollableFn, (_) => false, { intervalMs: 20, maxTimeoutMs: 78 });
50+
51+
// In theory, this is what just happened:
52+
// | time elapsed | value (source) |
53+
// |--------------+-----------------|
54+
// | 0ms | 1 (poll) |
55+
// | 20ms | 2 (poll) |
56+
// | 40ms | 3 (poll) |
57+
// | 60ms | 4 (poll) |
58+
// | 78ms | false (timeout) |
59+
//
60+
// In practice, since the polling intervalMs isn't started until after `apiFn`
61+
// resolves to a value, the actual call counts are slightly nondeterministic. We
62+
// could ignore that fact with a slow enough intervalMs, but who wants slow tests?
63+
expect(lastCalledValue > 2).toBe(true);
64+
expect(lastCalledValue <= 4).toBe(true);
65+
});
66+
});
67+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { timer, delay, from, concatMap, takeWhile, last, raceWith, lastValueFrom, NEVER } from "rxjs";
2+
3+
// Known issues:
4+
// - the case where `apiFn` returns `false` and `condition(false) === true` is impossible to distinguish from a timeout
5+
export function pollUntil<ResponseType>(
6+
apiFn: () => Promise<ResponseType>,
7+
condition: (res: ResponseType) => boolean,
8+
options: { intervalMs: number; maxTimeoutMs?: number }
9+
) {
10+
const { intervalMs, maxTimeoutMs } = options;
11+
const poll$ = timer(0, intervalMs).pipe(
12+
concatMap(() => from(apiFn())),
13+
takeWhile((result) => !condition(result), true),
14+
last()
15+
);
16+
17+
const timeout$ = maxTimeoutMs ? from([false]).pipe(delay(maxTimeoutMs)) : NEVER;
18+
19+
return lastValueFrom(poll$.pipe(raceWith(timeout$)));
20+
}

airbyte-webapp/src/packages/cloud/components/experiments/FreeConnectorProgram/hooks/useFreeConnectorProgram.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { useEffectOnce } from "react-use";
77
import { ToastType } from "components/ui/Toast";
88

99
import { MissingConfigError, useConfig } from "config";
10+
import { pollUntil } from "core/request/pollUntil";
11+
import { useAppMonitoringService } from "hooks/services/AppMonitoringService";
1012
import { useExperiment } from "hooks/services/Experiment";
1113
import { useNotificationService } from "hooks/services/Notification";
1214
import { useDefaultRequestMiddlewares } from "services/useDefaultRequestMiddlewares";
@@ -30,16 +32,32 @@ export const useFreeConnectorProgram = () => {
3032
const [userDidEnroll, setUserDidEnroll] = useState(false);
3133
const { formatMessage } = useIntl();
3234
const { registerNotification } = useNotificationService();
35+
const { trackError } = useAppMonitoringService();
3336

3437
useEffectOnce(() => {
3538
if (searchParams.has(STRIPE_SUCCESS_QUERY)) {
3639
// Remove the stripe parameter from the URL
37-
setSearchParams({}, { replace: true });
38-
setUserDidEnroll(true);
39-
registerNotification({
40-
id: "fcp/enrolled",
41-
text: formatMessage({ id: "freeConnectorProgram.enroll.success" }),
42-
type: ToastType.SUCCESS,
40+
pollUntil(
41+
() => webBackendGetFreeConnectorProgramInfoForWorkspace({ workspaceId }, requestOptions),
42+
({ hasPaymentAccountSaved }) => hasPaymentAccountSaved,
43+
{ intervalMs: 1000, maxTimeoutMs: 10000 }
44+
).then((maybeFcpInfo) => {
45+
if (maybeFcpInfo) {
46+
setSearchParams({}, { replace: true });
47+
setUserDidEnroll(true);
48+
registerNotification({
49+
id: "fcp/enrollment-success",
50+
text: formatMessage({ id: "freeConnectorProgram.enroll.success" }),
51+
type: ToastType.SUCCESS,
52+
});
53+
} else {
54+
trackError(new Error("Unable to confirm Free Connector Program enrollment before timeout"), { workspaceId });
55+
registerNotification({
56+
id: "fcp/enrollment-failure",
57+
text: formatMessage({ id: "freeConnectorProgram.enroll.failure" }),
58+
type: ToastType.ERROR,
59+
});
60+
}
4361
});
4462
}
4563
});

airbyte-webapp/src/packages/cloud/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@
182182
"freeConnectorProgram.enrollNow": "Enroll now!",
183183
"freeConnectorProgram.enroll.description": "Enroll in the <b>Free Connector Program</b> to use Alpha and Beta connectors for <b>free</b>.",
184184
"freeConnectorProgram.enroll.success": "Successfully enrolled in the Free Connector Program",
185+
"freeConnectorProgram.enroll.failure": "Unable to verify that payment details were saved successfully. Please contact support for additional help.",
185186
"freeConnectorProgram.enrollmentModal.title": "Free connector program",
186187
"freeConnectorProgram.enrollmentModal.free": "<p1>Alpha and Beta Connectors are free while you're in the program.</p1><p2>The whole Connection is free until both Connectors have moved into General Availability (GA)</p2>",
187188
"freeConnectorProgram.enrollmentModal.emailNotification": "We will email you before your connection will start being charged.",

0 commit comments

Comments
 (0)