Skip to content

Commit b4680c2

Browse files
committed
chore(davinci-client): refactor and improve polling
1 parent f6abd49 commit b4680c2

14 files changed

Lines changed: 674 additions & 250 deletions

.changeset/lucky-parts-own.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@forgerock/sdk-request-middleware': minor
3+
'@forgerock/davinci-client': minor
4+
---
5+
6+
Support both challenge polling and continue polling in DaVinci

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+
}

0 commit comments

Comments
 (0)