Skip to content

Commit 82a5d8d

Browse files
committed
chore(davinci-client): refactor and improve polling
1 parent e5ad583 commit 82a5d8d

10 files changed

Lines changed: 464 additions & 250 deletions

File tree

e2e/davinci-app/components/polling.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export default function pollingComponent(
2525
formEl.appendChild(button);
2626

2727
button.onclick = async () => {
28+
button.disabled = true;
29+
2830
const p = document.createElement('p');
2931
p.innerText = 'Polling...';
3032
formEl?.appendChild(p);
@@ -54,5 +56,7 @@ export default function pollingComponent(
5456
formEl?.appendChild(resultEl);
5557

5658
await submitForm();
59+
60+
button.disabled = false;
5761
};
5862
}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
/*
2+
* Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
8+
import { Micro } from 'effect';
9+
import { exitIsSuccess, exitIsFail } from 'effect/Micro';
10+
import { SerializedError } from '@reduxjs/toolkit/react';
11+
import { FetchBaseQueryError } from '@reduxjs/toolkit/query/react';
12+
13+
import { isGenericError } from '@forgerock/sdk-utilities';
14+
import type { logger as loggerFn } from '@forgerock/sdk-logger';
15+
16+
import type { ClientStore, RootState } from './client.store.utils.js';
17+
import type { PollingStatus, InternalErrorResponse } from './client.types.js';
18+
import type { PollingCollector } from './collector.types.js';
19+
import type { ContinueNode } from './node.types.js';
20+
21+
import { createInternalError } from './client.store.utils.js';
22+
import { davinciApi } from './davinci.api.js';
23+
import { nodeSlice } from './node.slice.js';
24+
25+
/**
26+
* Shape returned by RTK Query's dispatch for the poll endpoint.
27+
*/
28+
export interface PollDispatchResult {
29+
data?: unknown;
30+
error?: FetchBaseQueryError | SerializedError;
31+
}
32+
33+
/**
34+
* Validated prerequisites needed to initiate challenge polling.
35+
*/
36+
interface PollingPrerequisites {
37+
interactionId: string;
38+
challengeEndpoint: string;
39+
}
40+
41+
/**
42+
* Type guard: determines if a value is a plain object (Record<string, unknown>).
43+
*/
44+
const isRecord = (value: unknown): value is Record<string, unknown> =>
45+
typeof value === 'object' && value !== null;
46+
47+
/**
48+
* Selects and validates server is in continue state from root state.
49+
* Narrows from the NodeStates server union to ContinueNode['server'].
50+
* Throws InternalErrorResponse on validation failure.
51+
*/
52+
function selectContinueServer(rootState: RootState): ContinueNode['server'] {
53+
const server = nodeSlice.selectors.selectServer(rootState);
54+
55+
if (server === null) {
56+
throw createInternalError('No server info found for poll operation', 'state_error');
57+
}
58+
59+
if (isGenericError(server)) {
60+
throw createInternalError(
61+
server.message ?? 'Failed to retrieve server info for poll operation',
62+
);
63+
}
64+
65+
if (server.status !== 'continue') {
66+
throw createInternalError(
67+
'Not in a continue node state, must be in a continue node to use poll method',
68+
'state_error',
69+
);
70+
}
71+
72+
return server;
73+
}
74+
75+
/**
76+
* Extracts the self link href from server links.
77+
* Throws InternalErrorResponse if self link is missing.
78+
*/
79+
function selectSelfLink(server: ContinueNode['server']): string {
80+
const links = server._links;
81+
if (!links || !('self' in links) || !('href' in links['self']) || !links['self'].href) {
82+
throw createInternalError(
83+
'No self link found in server info for challenge polling operation',
84+
'state_error',
85+
);
86+
}
87+
return links['self'].href;
88+
}
89+
90+
/**
91+
* Constructs the challenge polling endpoint from a self link URL.
92+
* Throws InternalErrorResponse if URL cannot be parsed.
93+
*/
94+
function buildChallengeEndpoint(selfHref: string, challenge: string): string {
95+
const url = new URL(selfHref);
96+
const envId = url.pathname.split('/')[1];
97+
98+
if (!url.origin || !envId) {
99+
throw createInternalError(
100+
'Failed to construct challenge polling endpoint. Requires host and environment ID.',
101+
'parse_error',
102+
);
103+
}
104+
105+
return `${url.origin}/${envId}/davinci/user/credentials/challenge/${challenge}/status`;
106+
}
107+
108+
/**
109+
* Pure function: validates all prerequisites for challenge polling and constructs the endpoint URL.
110+
* Composes selectors that each extract and validate a piece of state.
111+
* Throws InternalErrorResponse on any validation failure — designed to be caught by Micro.try.
112+
*/
113+
export function validatePollingPrerequisites(
114+
rootState: RootState,
115+
challenge: string,
116+
): PollingPrerequisites {
117+
if (!challenge) {
118+
throw createInternalError('No challenge found on collector for poll operation', 'state_error');
119+
}
120+
121+
const server = selectContinueServer(rootState);
122+
const selfHref = selectSelfLink(server);
123+
const challengeEndpoint = buildChallengeEndpoint(selfHref, challenge);
124+
125+
if (!server.interactionId) {
126+
throw createInternalError(
127+
'Missing interactionId in server info for challenge polling',
128+
'state_error',
129+
);
130+
}
131+
132+
return { interactionId: server.interactionId, challengeEndpoint };
133+
}
134+
135+
/**
136+
* Pure predicate: determines if challenge polling should continue.
137+
* Returns true when the challenge has not yet completed and no error occurred.
138+
*/
139+
export function isChallengeStillPending(response: PollDispatchResult): boolean {
140+
if (response.error) return false;
141+
142+
const data = isRecord(response.data) ? response.data : undefined;
143+
if (data?.['isChallengeComplete']) return false;
144+
145+
return true;
146+
}
147+
148+
export function interpretChallengeResponse(
149+
response: PollDispatchResult,
150+
log: ReturnType<typeof loggerFn>,
151+
): Micro.Micro<PollingStatus, InternalErrorResponse> {
152+
const { data, error } = response;
153+
154+
if (error) {
155+
// FetchBaseQueryError — has status field
156+
if ('status' in error) {
157+
const errorDetails = isRecord(error.data) ? error.data : undefined;
158+
const serviceName = errorDetails?.['serviceName'];
159+
160+
// Expired challenge is an expected polling outcome, not a failure
161+
if (error.status === 400 && serviceName === 'challengeExpired') {
162+
log.debug('Challenge expired for polling');
163+
return Micro.succeed('expired' as PollingStatus);
164+
}
165+
166+
// Other HTTP errors are also expected outcomes (e.g. bad challenge returning 400 with code 4019)
167+
log.debug('Unknown error occurred during polling');
168+
return Micro.succeed('error' as PollingStatus);
169+
}
170+
171+
// SerializedError — has message field
172+
const message =
173+
'message' in error && error.message
174+
? error.message
175+
: 'An unknown error occurred while challenge polling';
176+
177+
return Micro.fail(createInternalError(message, 'unknown_error'));
178+
}
179+
180+
if (!isRecord(data)) {
181+
log.debug('Unable to parse polling response');
182+
return Micro.succeed('error' as PollingStatus);
183+
}
184+
const pollResponse = data;
185+
186+
// Challenge completed — extract status
187+
if (pollResponse['isChallengeComplete'] === true) {
188+
const pollStatus = pollResponse['status'];
189+
return pollStatus
190+
? Micro.succeed(pollStatus as PollingStatus)
191+
: Micro.succeed('error' as PollingStatus);
192+
}
193+
194+
// If we reach here, Micro.repeat exhausted its schedule without the challenge completing
195+
log.debug('Challenge polling timed out');
196+
return Micro.succeed('timedOut' as PollingStatus);
197+
}
198+
199+
/**
200+
* Orchestrates challenge polling using extracted pure functions.
201+
* The shell: reads state, dispatches effects, runs the pipeline at the boundary.
202+
*/
203+
export async function handleChallengePolling({
204+
collector,
205+
challenge,
206+
store,
207+
log,
208+
}: {
209+
collector: PollingCollector;
210+
challenge: string;
211+
store: ReturnType<ClientStore>;
212+
log: ReturnType<typeof loggerFn>;
213+
}): Promise<PollingStatus | InternalErrorResponse> {
214+
const maxRetries = collector.output.config.pollRetries ?? 60;
215+
const pollInterval = collector.output.config.pollInterval ?? 2000;
216+
217+
// validate → query → repeat → interpret
218+
const challengePollµ = Micro.try({
219+
try: () => validatePollingPrerequisites(store.getState(), challenge),
220+
catch: (error) => error as InternalErrorResponse,
221+
}).pipe(
222+
Micro.flatMap(({ interactionId, challengeEndpoint }) =>
223+
Micro.promise(() =>
224+
store.dispatch(
225+
davinciApi.endpoints.poll.initiate({
226+
endpoint: challengeEndpoint,
227+
interactionId,
228+
}),
229+
),
230+
).pipe(
231+
Micro.repeat({
232+
while: isChallengeStillPending,
233+
// `times` - tracks repetitions automatically and stops when the threshold is reached
234+
// - counts the number of retries after the initial attempt so decrement by one
235+
times: maxRetries - 1,
236+
schedule: Micro.scheduleSpaced(pollInterval),
237+
}),
238+
),
239+
),
240+
Micro.flatMap((response) => interpretChallengeResponse(response, log)),
241+
Micro.tapError(({ error }) => Micro.sync(() => log.error(error.message))),
242+
);
243+
244+
const result = await Micro.runPromiseExit(challengePollµ);
245+
246+
if (exitIsSuccess(result)) {
247+
return result.value;
248+
} else if (exitIsFail(result)) {
249+
return result.cause.error;
250+
}
251+
252+
return {
253+
error: { message: result.cause.message, type: 'unknown_error' },
254+
type: 'internal_error',
255+
};
256+
}

packages/davinci-client/src/lib/client.store.ts

Lines changed: 34 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
66
*/
7-
import { Micro } from 'effect';
8-
import { CustomLogger, logger as loggerFn, LogLevel } from '@forgerock/sdk-logger';
7+
import { type CustomLogger, logger as loggerFn, type LogLevel } from '@forgerock/sdk-logger';
98
import { createStorage } from '@forgerock/storage';
109
import { isGenericError, createWellknownError } from '@forgerock/sdk-utilities';
1110

@@ -14,10 +13,12 @@ import { isGenericError, createWellknownError } from '@forgerock/sdk-utilities';
1413
*/
1514
import {
1615
createClientStore,
17-
handleChallengePolling,
16+
createInternalError,
17+
getPollingMode,
1818
handleUpdateValidateError,
19-
RootState,
19+
type RootState,
2020
} from './client.store.utils.js';
21+
import { handleChallengePolling } from './client.store.effects.js';
2122
import { nodeSlice } from './node.slice.js';
2223
import { davinciApi } from './davinci.api.js';
2324
import { configSlice } from './config.slice.js';
@@ -419,67 +420,47 @@ export async function davinci<ActionType extends ActionTypes = ActionTypes>({
419420
*/
420421
poll: async (collector: PollingCollector): Promise<PollingStatus | InternalErrorResponse> => {
421422
try {
422-
if (collector.type !== 'PollingCollector') {
423-
log.error('Collector provided to poll is not a PollingCollector');
424-
return {
425-
error: {
426-
message: 'Collector provided to poll is not a PollingCollector',
427-
type: 'argument_error',
428-
},
429-
type: 'internal_error',
430-
};
431-
}
423+
const mode = getPollingMode(collector);
432424

433-
const pollChallengeStatus = collector.output.config.pollChallengeStatus;
434-
const challenge = collector.output.config.challenge;
435-
436-
if (challenge && pollChallengeStatus === true) {
437-
// Challenge Polling
425+
if (mode._tag === 'challenge') {
426+
// Challenge polling mode
438427
return await handleChallengePolling({
439428
collector,
440-
challenge,
429+
challenge: mode.challenge,
441430
store,
442431
log,
443432
});
444-
} else if (!challenge && !pollChallengeStatus) {
445-
// Continue polling
446-
const retriesLeft = collector.output.config.retriesRemaining;
447-
const pollInterval = collector.output.config.pollInterval ?? 2000; // miliseconds
433+
} else if (mode._tag === 'continue') {
434+
// Continue polling mode
448435

449-
if (retriesLeft === undefined) {
450-
log.error('No retries found on PollingCollector');
451-
return {
452-
error: {
453-
message: 'No retries found on PollingCollector',
454-
type: 'argument_error',
455-
},
456-
type: 'internal_error',
457-
};
458-
}
459-
460-
if (retriesLeft > 0) {
461-
const getStatusµ = Micro.sync(() => 'continue' as PollingStatus).pipe(
462-
Micro.delay(pollInterval),
463-
);
464-
const status: PollingStatus = await Micro.runPromise(getStatusµ);
465-
return status;
466-
} else {
436+
if (mode.retriesRemaining <= 0) {
467437
// Retries exhausted
468-
return 'timedOut' as PollingStatus;
438+
return 'timedOut';
469439
}
440+
441+
// If there are still retries, wait for the poll interval then return a 'continue' status
442+
await new Promise((resolve) => setTimeout(resolve, mode.pollInterval));
443+
return 'continue';
470444
} else {
471-
// Error if polling type can't be determined from configuration
472-
log.error('Invalid polling collector configuration');
473-
return {
474-
error: {
475-
message: 'Invalid polling collector configuration',
476-
type: 'internal_error',
477-
},
478-
type: 'internal_error',
479-
};
445+
// Error if polling mode can't be determined from configuration
446+
throw createInternalError('Invalid polling collector configuration', 'argument_error');
480447
}
481448
} catch (err) {
482-
const errorMessage = err instanceof Error ? err.message : String(err);
449+
let errorMessage = '';
450+
451+
if (err instanceof Error) {
452+
errorMessage = err.message;
453+
} else if (
454+
err !== null &&
455+
typeof err === 'object' &&
456+
'type' in err &&
457+
err.type === 'internal_error'
458+
) {
459+
const internalError = err as InternalErrorResponse;
460+
log.error(internalError.error.message);
461+
return internalError;
462+
}
463+
483464
log.error(errorMessage);
484465
return {
485466
error: {

0 commit comments

Comments
 (0)