Skip to content

Commit 5835985

Browse files
authored
fix: create a fresh default polling strategy per request (#1149)
Setting the `defaultStrategy()` on the `DEFAULT_POLLING_OPTIONS` statically results in calling the `timeout` strategy in the chain and starting the timeout. This makes all the read state requests after update calls fail after the configured `FIVE_MINUTES_IN_MSEC`, no matter whether the polling already started or not. This PR fixes the bug by removing the `defaultStrategy()` on the `DEFAULT_POLLING_OPTIONS` and setting it in the `pollForResponse` call. Ideally, the strategy property in the `pollingOptions` should be a function that returns a `PollStrategy`, but that was unfortunately changed in 880f18e#diff-8e88073ceeb119a7b39f3e6616db4c23f5d3c0e923c194f1f0e5a1a6d54f5835L99. It would require us to make a breaking change, which is not viable at the moment. Additionally, removes the `PollStrategyFactory` type, which was not used anywhere.
1 parent 3d46471 commit 5835985

File tree

5 files changed

+259
-4
lines changed

5 files changed

+259
-4
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## [Unreleased]
44

5+
- fix(agent): create a fresh default polling strategy per request.
6+
- fix(agent): remove the unused `PollStrategyFactory` type.
57
- fix(agent): remove the `nonce` from the `ActorConfig` type. This field must be used through the `CallConfig` type instead.
68

79
## [4.0.3] - 2025-09-16
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { IDL } from '@dfinity/candid';
2+
import { Principal } from '@dfinity/principal';
3+
import { type Agent } from './agent/api.ts';
4+
import { RequestId } from './request_id.ts';
5+
import { type LookupPathStatus, type LookupPathResultFound } from './certificate.ts';
6+
7+
// Track strategy creations and invocations
8+
const instantiatedStrategies: jest.Mock[] = [];
9+
jest.mock('./polling/strategy.ts', () => {
10+
return {
11+
defaultStrategy: jest.fn(() => {
12+
const fn = jest.fn(async () => {
13+
// no-op strategy used in tests
14+
});
15+
instantiatedStrategies.push(fn);
16+
return fn;
17+
}),
18+
};
19+
});
20+
21+
const textEncoder = new TextEncoder();
22+
const textDecoder = new TextDecoder();
23+
24+
const statusesByRequestId = new Map<RequestId, string[]>();
25+
const replyByRequestId = new Map<RequestId, Uint8Array>();
26+
27+
jest.mock('./certificate.ts', () => {
28+
return {
29+
lookupResultToBuffer: (res: { value: Uint8Array }) => res?.value,
30+
Certificate: {
31+
create: jest.fn(async () => {
32+
return {
33+
lookup_path: (path: [string, RequestId, string]): LookupPathResultFound => {
34+
// Path shape: ['request_status', requestIdBytes, 'status'|'reject_code'|'reject_message'|'error_code'|'reply']
35+
const requestIdBytes = path[1];
36+
const lastPathElement = path[path.length - 1] as string | Uint8Array;
37+
const lastPathElementStr =
38+
typeof lastPathElement === 'string'
39+
? lastPathElement
40+
: textDecoder.decode(lastPathElement);
41+
42+
if (lastPathElementStr === 'status') {
43+
const q = statusesByRequestId.get(requestIdBytes) ?? [];
44+
const current = q.length > 0 ? q.shift()! : 'replied';
45+
statusesByRequestId.set(requestIdBytes, q);
46+
return {
47+
status: 'Found' as LookupPathStatus.Found,
48+
value: textEncoder.encode(current),
49+
};
50+
}
51+
if (lastPathElementStr === 'reply') {
52+
return {
53+
status: 'Found' as LookupPathStatus.Found,
54+
value: replyByRequestId.get(requestIdBytes)!,
55+
};
56+
}
57+
throw new Error(`Unexpected lastPathElementStr ${lastPathElementStr}`);
58+
},
59+
} as const;
60+
}),
61+
},
62+
};
63+
});
64+
65+
describe('Actor default polling options are not reused across calls', () => {
66+
beforeEach(() => {
67+
instantiatedStrategies.length = 0;
68+
statusesByRequestId.clear();
69+
replyByRequestId.clear();
70+
jest.resetModules();
71+
});
72+
73+
it('instantiates a fresh defaultStrategy per update call when using DEFAULT_POLLING_OPTIONS', async () => {
74+
const { Actor } = await import('./actor.ts');
75+
const defaultStrategy = (await import('./polling/strategy.ts')).defaultStrategy as jest.Mock;
76+
77+
const canisterId = Principal.anonymous();
78+
79+
const requestIdA = new Uint8Array([1, 2, 3]) as RequestId;
80+
const requestIdB = new Uint8Array([4, 5, 6]) as RequestId;
81+
statusesByRequestId.set(requestIdA, ['processing', 'replied']);
82+
statusesByRequestId.set(requestIdB, ['unknown', 'replied']);
83+
84+
const expectedReplyArgA = IDL.encode([IDL.Text], ['okA']);
85+
const expectedReplyArgB = IDL.encode([IDL.Text], ['okB']);
86+
replyByRequestId.set(requestIdA, expectedReplyArgA);
87+
replyByRequestId.set(requestIdB, expectedReplyArgB);
88+
89+
// Fake Agent that forces polling (202) and provides readState
90+
let callCount = 0;
91+
const fakeAgent = {
92+
rootKey: new Uint8Array([1]),
93+
call: async () => {
94+
const requestId = callCount === 0 ? requestIdA : requestIdB;
95+
callCount += 1;
96+
return {
97+
requestId,
98+
response: { status: 202 },
99+
reply: replyByRequestId.get(requestId)!,
100+
requestDetails: {},
101+
} as unknown as {
102+
requestId: Uint8Array;
103+
response: { status: number };
104+
requestDetails: object;
105+
};
106+
},
107+
readState: async () => ({ certificate: new Uint8Array([0]) }),
108+
} as unknown as Agent;
109+
110+
// Simple update method to trigger poll
111+
const actorInterface = () =>
112+
IDL.Service({
113+
upd: IDL.Func([IDL.Text], [IDL.Text]),
114+
});
115+
116+
const actor = Actor.createActor(actorInterface, {
117+
canisterId,
118+
// Critically, no pollingOptions override; Actor uses DEFAULT_POLLING_OPTIONS
119+
// which must not carry a pre-instantiated strategy
120+
agent: fakeAgent,
121+
});
122+
123+
const outA = await actor.upd('x');
124+
const outB = await actor.upd('y');
125+
expect(outA).toBe('okA');
126+
expect(outB).toBe('okB');
127+
128+
// defaultStrategy should have been created once per call, not shared
129+
expect(defaultStrategy.mock.calls.length).toBe(2);
130+
// Each created strategy used at least once
131+
expect(instantiatedStrategies.length).toBe(2);
132+
expect(instantiatedStrategies[0].mock.calls.length).toBe(1);
133+
expect(instantiatedStrategies[1].mock.calls.length).toBe(1);
134+
});
135+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { Principal } from '@dfinity/principal';
2+
import { type Agent } from '../agent/api.ts';
3+
import { type RequestId } from '../request_id.ts';
4+
import { type LookupPathResultFound, type LookupPathStatus } from '../certificate.ts';
5+
6+
// Mock the strategy module to observe instantiation and usage
7+
const instantiatedStrategies: jest.Mock[] = [];
8+
jest.mock('./strategy.ts', () => {
9+
return {
10+
// Each call should create a fresh, stateful strategy function
11+
defaultStrategy: jest.fn(() => {
12+
const strategyFn = jest.fn(async () => {
13+
// no-op strategy used in tests
14+
});
15+
instantiatedStrategies.push(strategyFn);
16+
return strategyFn;
17+
}),
18+
};
19+
});
20+
21+
const textEncoder = new TextEncoder();
22+
const textDecoder = new TextDecoder();
23+
24+
// Map a requestId key to a queue of statuses to emit across polls
25+
const statusesByRequestKey = new Map<RequestId, string[]>();
26+
const replyByRequestKey = new Map<RequestId, Uint8Array>();
27+
28+
const mockAgent = {
29+
rootKey: new Uint8Array([1]),
30+
readState: async () => ({ certificate: new Uint8Array([0]) }),
31+
} as unknown as Agent;
32+
33+
jest.mock('../certificate.ts', () => {
34+
return {
35+
// Simplified adapter used by polling/index.ts
36+
lookupResultToBuffer: (res: LookupPathResultFound) => res.value,
37+
Certificate: {
38+
create: jest.fn(async () => {
39+
return {
40+
lookup_path: (path: [string, RequestId, string]): LookupPathResultFound => {
41+
// Path shape: ['request_status', requestIdBytes, 'status'|'reject_code'|'reject_message'|'error_code'|'reply']
42+
const requestIdBytes = path[1];
43+
const lastPathElement = path[path.length - 1] as string | Uint8Array;
44+
const lastPathElementStr =
45+
typeof lastPathElement === 'string'
46+
? lastPathElement
47+
: textDecoder.decode(lastPathElement);
48+
49+
if (lastPathElementStr === 'status') {
50+
const q = statusesByRequestKey.get(requestIdBytes) ?? [];
51+
const current = q.length > 0 ? q.shift()! : 'replied';
52+
statusesByRequestKey.set(requestIdBytes, q);
53+
return {
54+
status: 'Found' as LookupPathStatus.Found,
55+
value: textEncoder.encode(current),
56+
};
57+
}
58+
if (lastPathElementStr === 'reply') {
59+
return {
60+
status: 'Found' as LookupPathStatus.Found,
61+
value: replyByRequestKey.get(requestIdBytes)!,
62+
};
63+
}
64+
throw new Error(`Unexpected lastPathElementStr ${lastPathElementStr}`);
65+
},
66+
} as const;
67+
}),
68+
},
69+
};
70+
});
71+
72+
describe('pollForResponse strategy lifecycle', () => {
73+
beforeEach(() => {
74+
instantiatedStrategies.length = 0;
75+
statusesByRequestKey.clear();
76+
replyByRequestKey.clear();
77+
jest.resetModules();
78+
});
79+
80+
it('creates a fresh default strategy per request and reuses it across retries', async () => {
81+
// We need to import the module here to make sure the mock is applied
82+
const { pollForResponse, defaultStrategy } = await import('./index.ts');
83+
84+
const canisterId = Principal.anonymous();
85+
86+
// Request A: simulate three polls: processing -> unknown -> replied
87+
const requestIdA = new Uint8Array([1, 2, 3]) as RequestId;
88+
statusesByRequestKey.set(requestIdA, ['processing', 'unknown', 'replied']);
89+
replyByRequestKey.set(requestIdA, new Uint8Array([42]));
90+
91+
// Request B: simulate two polls: unknown -> replied
92+
const requestIdB = new Uint8Array([9, 8, 7]) as RequestId;
93+
statusesByRequestKey.set(requestIdB, ['unknown', 'replied']);
94+
replyByRequestKey.set(requestIdB, new Uint8Array([99]));
95+
96+
// First call
97+
const responseA = await pollForResponse(mockAgent, canisterId, requestIdA);
98+
expect(responseA.reply).toEqual(new Uint8Array([42]));
99+
100+
// Second independent call
101+
const responseB = await pollForResponse(mockAgent, canisterId, requestIdB);
102+
expect(responseB.reply).toEqual(new Uint8Array([99]));
103+
104+
// Assert that defaultStrategy has been instantiated once per request (not per retry)
105+
const defaultStrategyMock = defaultStrategy as jest.Mock;
106+
expect(defaultStrategyMock.mock.calls.length).toBe(2);
107+
108+
// And that each created strategy function was invoked during its own request
109+
expect(instantiatedStrategies.length).toBe(2);
110+
// Request A had two non-replied statuses, so strategy called at least twice
111+
expect(instantiatedStrategies[0].mock.calls.length).toBe(2);
112+
// Request B had one non-replied status (unknown), so strategy called once
113+
expect(instantiatedStrategies[1].mock.calls.length).toBe(1);
114+
});
115+
});

packages/agent/src/polling/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@ export type PollStrategy = (
3131
status: RequestStatusResponseStatus,
3232
) => Promise<void>;
3333

34-
export type PollStrategyFactory = () => PollStrategy;
35-
3634
interface SignedReadStateRequestWithExpiry extends ReadStateRequest {
3735
body: {
3836
content: Pick<ReadStateRequest, 'request_type' | 'ingress_expiry'>;
@@ -46,7 +44,7 @@ export interface PollingOptions {
4644
/**
4745
* A polling strategy that dictates how much and often we should poll the
4846
* read_state endpoint to get the result of an update call.
49-
* @default defaultStrategy()
47+
* @default {@link defaultStrategy}
5048
*/
5149
strategy?: PollStrategy;
5250

@@ -69,7 +67,6 @@ export interface PollingOptions {
6967
}
7068

7169
export const DEFAULT_POLLING_OPTIONS: PollingOptions = {
72-
strategy: defaultStrategy(),
7370
preSignReadStateRequest: false,
7471
};
7572

@@ -187,8 +184,11 @@ export async function pollForResponse(
187184
// Execute the polling strategy, then retry.
188185
const strategy = options.strategy ?? defaultStrategy();
189186
await strategy(canisterId, requestId, status);
187+
190188
return pollForResponse(agent, canisterId, requestId, {
191189
...options,
190+
// Pass over either the strategy already provided or the new one created above
191+
strategy,
192192
request: currentRequest,
193193
});
194194
}

packages/agent/src/polling/strategy.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ const FIVE_MINUTES_IN_MSEC = 5 * 60 * 1000;
1515
/**
1616
* A best practices polling strategy: wait 2 seconds before the first poll, then 1 second
1717
* with an exponential backoff factor of 1.2. Timeout after 5 minutes.
18+
*
19+
* Note that calling this function will create the strategy chain described above and already start the 5 minutes timeout.
20+
* You should only call this function when you want to start the polling, and not before, to avoid exhausting the 5 minutes timeout in advance.
1821
*/
1922
export function defaultStrategy(): PollStrategy {
2023
return chain(conditionalDelay(once(), 1000), backoff(1000, 1.2), timeout(FIVE_MINUTES_IN_MSEC));

0 commit comments

Comments
 (0)