Skip to content

Commit 365f29d

Browse files
abueideclaude
andauthored
feat(core): integrate RetryManager into SegmentDestination upload pipeline (#1160)
* fix: add persistence validation, atomic backoff calc, and 429 precedence - Validate persisted state in canRetry() to handle clock changes/corruption per SDD §Metadata Lifecycle - Move backoff calculation inside dispatch to avoid stale retryCount from concurrent batch failures (handleErrorWithBackoff) - Ensure RATE_LIMITED state is never downgraded to BACKING_OFF - Update reset() docstring to clarify when it should be called Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: clean up RetryManager comments for post-merge readability Simplify class docstring to describe current architecture without referencing SDD deviations. Remove redundant inline comments, compact JSDoc to single-line where appropriate, and ensure all comments use present tense. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: consolidate error handlers, fix getState race, add limit signaling - Use getState(true) for queue-safe reads to prevent race conditions between concurrent canRetry/handle429/handleTransientError calls - Consolidate handleError and handleErrorWithBackoff into a single method that accepts a computeWaitUntilTime function - Extract side effects (logging, Math.random) from dispatch reducers - Return RetryResult ('rate_limited'|'backed_off'|'limit_exceeded') from handle429/handleTransientError so callers can drop events on limit exceeded - Clear auto-flush timer in transitionToReady - Validate state string in isPersistedStateValid Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: trim verbose inline comments in RetryManager Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: use enums and switch statements for clearer state handling Replace string literals with TypeScript enums: - RetryState enum (READY, RATE_LIMITED, BACKING_OFF) - RetryResult enum (RATE_LIMITED, BACKED_OFF, LIMIT_EXCEEDED) Extract helper methods for clarity: - resolveStatePrecedence(): handles 429 taking priority over backoff - consolidateWaitTime(): uses switch statement for clear wait time logic - getStateDisplayName(): maps state to display names Benefits: - Type-safe state handling (no magic strings) - Switch statements make control flow explicit - Each helper method has a single, named responsibility - Easier to test and maintain Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: remove VALID_STATES Set, use Object.values for enum validation Use Object.values(RetryState).includes() instead of maintaining a duplicate Set of valid states. More idiomatic TypeScript and eliminates maintenance burden of keeping Set in sync with enum. * test: add 429 authority test and fix autoFlush timer cleanup - Add test verifying 429 Retry-After overrides long transient backoff - Track RetryManager instances in autoFlush tests and destroy in afterEach to prevent timer leaks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: update tests for refactored API and add missing coverage Update log assertions for consolidated limit-exceeded message. Add tests for: RetryResult return values, jitter calculation, isPersistedStateValid clock skew detection, handle429(0) edge case, and strengthened assertions that verify behavioral state after clamping/rejection (not just log output). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add persistence validation, atomic backoff calc, and 429 precedence - Validate persisted state in canRetry() to handle clock changes/corruption per SDD §Metadata Lifecycle - Move backoff calculation inside dispatch to avoid stale retryCount from concurrent batch failures (handleErrorWithBackoff) - Ensure RATE_LIMITED state is never downgraded to BACKING_OFF - Update reset() docstring to clarify when it should be called Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: clean up RetryManager comments for post-merge readability Simplify class docstring to describe current architecture without referencing SDD deviations. Remove redundant inline comments, compact JSDoc to single-line where appropriate, and ensure all comments use present tense. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: consolidate error handlers, fix getState race, add limit signaling - Use getState(true) for queue-safe reads to prevent race conditions between concurrent canRetry/handle429/handleTransientError calls - Consolidate handleError and handleErrorWithBackoff into a single method that accepts a computeWaitUntilTime function - Extract side effects (logging, Math.random) from dispatch reducers - Return RetryResult ('rate_limited'|'backed_off'|'limit_exceeded') from handle429/handleTransientError so callers can drop events on limit exceeded - Clear auto-flush timer in transitionToReady - Validate state string in isPersistedStateValid Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: trim verbose inline comments in RetryManager Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: use enums and switch statements for clearer state handling Replace string literals with TypeScript enums: - RetryState enum (READY, RATE_LIMITED, BACKING_OFF) - RetryResult enum (RATE_LIMITED, BACKED_OFF, LIMIT_EXCEEDED) Extract helper methods for clarity: - resolveStatePrecedence(): handles 429 taking priority over backoff - consolidateWaitTime(): uses switch statement for clear wait time logic - getStateDisplayName(): maps state to display names Benefits: - Type-safe state handling (no magic strings) - Switch statements make control flow explicit - Each helper method has a single, named responsibility - Easier to test and maintain Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: remove VALID_STATES Set, use Object.values for enum validation Use Object.values(RetryState).includes() instead of maintaining a duplicate Set of valid states. More idiomatic TypeScript and eliminates maintenance burden of keeping Set in sync with enum. * feat(core): integrate RetryManager into SegmentDestination upload pipeline Wire RetryManager into SegmentDestination for TAPI-compliant retry handling: uploadBatch() with error classification, event pruning on partial failures, retry count header propagation, and QueueFlushingPlugin error type handling updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: partial success reset, 429 precedence, and cleanup in sendEvents - Don't reset retry state on partial success when concurrent batches have 429/transient errors - Use if/else if so 429 takes precedence over transient error handling - Remove redundant return Promise.resolve() in async function - Fix duplicate keepalive property from master merge Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review issues in SegmentDestination integration - Use res.ok instead of res.status === 200 for 2xx success range - Remove dead dequeue() method from QueueFlushingPlugin - Add destroy() to SegmentDestination for RetryManager timer cleanup - Reset retry state when queue is empty at flush time or after pruning - Call handle429 per result instead of pre-aggregating, so RetryManager.applyRetryStrategy respects eager/lazy consolidation - Simplify retryAfterSeconds fallback to ?? 60 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract helper methods and clean up comments in SegmentDestination Extract pruneExpiredEvents() and updateRetryState() from sendEvents to improve readability. Remove redundant/obvious comments, merge duplicate switch cases, and simplify return statements throughout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: wire shutdown lifecycle, handle limit-exceeded signal, age-based pruning - Override shutdown() instead of standalone destroy() to integrate with the plugin lifecycle — prevents auto-flush timer leak on client cleanup - Handle RetryResult 'limit_exceeded' from RetryManager: log warning and let per-event age pruning (pruneExpiredEvents via _queuedAt) handle event drops rather than dropping all retryable events on global counter reset - Import RetryResult type for type-safe limit checking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: restore pre-existing comments in SegmentDestination.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: always apply defaultHttpConfig, drop events on retry limit exceeded Root cause: when the CDN settings response had no httpConfig field, analytics.ts guarded the entire merge block with if (resJson.httpConfig), so this.httpConfig stayed undefined. This prevented RetryManager creation in SegmentDestination, disabling all retry features (error classification overrides, canRetry() gating, retry counting, maxRetries enforcement). Changes: - Remove httpConfig guard in analytics.ts — always merge defaultHttpConfig as baseline, with CDN and config.httpConfig overrides on top - Add httpConfig?: DeepPartial<HttpConfig> to Config type for client-side overrides (e.g. maxRetries from test harness) - Drop retryable events when retry limits exceeded in SegmentDestination instead of leaving them in the queue indefinitely - Update fetchSettings test to expect defaultHttpConfig when CDN has no httpConfig Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: trim verbose inline comments in SDK plugins and types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove auto-flush wiring from SegmentDestination, add CDN validation tests Remove autoFlushOnRetryReady check, setAutoFlushCallback call, and shutdown() method. Add tests for CDN integrations validation (null, array, string, and no-defaults scenarios). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: increment droppedEventCount at SegmentDestination drop sites Wire up the droppedEventCount counter (added in cli-flush-retry-loop) at the two places SegmentDestination permanently removes events: permanent errors and retry limit exceeded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: treat missing CDN integrations as empty, not as fallback trigger When CDN returns a valid 200 with no integrations field (e.g. {}), treat it as authoritative "no integrations configured" rather than falling back to defaultSettings. This ensures SegmentDestination is correctly disabled when the server has no integrations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: restore droppedEventCount property on SegmentDestination Property declaration was lost during branch rebase. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: replace droppedEventCount with reportInternalError at drop sites Report EventsDropped errors via errorHandler at all three drop sites: expired events, permanent HTTP errors, and retry limit exceeded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add metadata to EventsDropped errors with droppedCount and reason Pass structured metadata at all three drop sites so consumers can access droppedCount and reason without parsing strings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove retryStrategy param, update tests for eager behavior * refactor: improve SegmentDestination readability - Extract error classification into classifyBatchResult method - Add helper methods for config access (getRateLimitConfig, getBackoffConfig) - Consolidate drop reporting into reportDroppedEvents helper - Extract upload result processing into processUploadResults method - Use early returns in sendEvents for clearer control flow * fix: remove leftover activeManager reference from rebase * chore: remove review-agent-commit.sh and add *.local.sh to gitignore * fix: remove duplicate isPersistedStateValid calls in canRetry * refactor: extract httpConfig merging into reusable helper Consolidate duplicate 3-way config merging logic (default < CDN < client) into mergeHttpConfig() helper in config-validation.ts. Reduces analytics.ts by 42 lines and makes the merging logic testable and maintainable. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: omit X-Retry-Count header on first request attempt The X-Retry-Count header should only be sent on retry attempts (count > 0), not on the initial request. This aligns with updated e2e test expectations and standard retry header conventions. Changes: - api.ts: conditionally add X-Retry-Count only when retryCount > 0 - api.test.ts: update tests to verify header is omitted on first attempt - e2e-config.json: enable retry test suite Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aaf6348 commit 365f29d

14 files changed

Lines changed: 883 additions & 93 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,5 +97,8 @@ packages/core/src/info.ts
9797

9898
AGENTS.md
9999

100+
# Local files (not for commit)
101+
*.local.sh
102+
100103
# Notes and research (not for commit)
101104
notes/

e2e-cli/e2e-config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"sdk": "react-native",
3-
"test_suites": "basic,settings",
3+
"test_suites": "basic,settings,retry",
44
"auto_settings": true,
55
"patch": null,
66
"env": {

packages/core/src/__tests__/api.test.ts

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ describe('#sendEvents', () => {
2525
.mockReturnValue('2001-01-01T00:00:00.000Z');
2626
});
2727

28-
async function sendAnEventPer(writeKey: string, toUrl: string) {
28+
async function sendAnEventPer(
29+
writeKey: string,
30+
toUrl: string,
31+
retryCount?: number
32+
) {
2933
const mockResponse = Promise.resolve('MANOS');
3034
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
3135
// @ts-ignore
@@ -60,9 +64,19 @@ describe('#sendEvents', () => {
6064
writeKey: writeKey,
6165
url: toUrl,
6266
events: [event],
67+
retryCount,
6368
});
6469

65-
expect(fetch).toHaveBeenCalledWith(toUrl, {
70+
return event;
71+
}
72+
73+
it('sends an event', async () => {
74+
const toSegmentBatchApi = 'https://api.segment.io/v1.b';
75+
const writeKey = 'SEGMENT_KEY';
76+
77+
const event = await sendAnEventPer(writeKey, toSegmentBatchApi);
78+
79+
expect(fetch).toHaveBeenCalledWith(toSegmentBatchApi, {
6680
method: 'POST',
6781
keepalive: true,
6882
body: JSON.stringify({
@@ -74,19 +88,58 @@ describe('#sendEvents', () => {
7488
'Content-Type': 'application/json; charset=utf-8',
7589
},
7690
});
77-
}
78-
79-
it('sends an event', async () => {
80-
const toSegmentBatchApi = 'https://api.segment.io/v1.b';
81-
const writeKey = 'SEGMENT_KEY';
82-
83-
await sendAnEventPer(writeKey, toSegmentBatchApi);
8491
});
8592

8693
it('sends an event to proxy', async () => {
8794
const toProxyUrl = 'https://myprox.io/b';
8895
const writeKey = 'SEGMENT_KEY';
8996

90-
await sendAnEventPer(writeKey, toProxyUrl);
97+
const event = await sendAnEventPer(writeKey, toProxyUrl);
98+
99+
expect(fetch).toHaveBeenCalledWith(toProxyUrl, {
100+
method: 'POST',
101+
body: JSON.stringify({
102+
batch: [event],
103+
sentAt: '2001-01-01T00:00:00.000Z',
104+
writeKey: 'SEGMENT_KEY',
105+
}),
106+
headers: {
107+
'Content-Type': 'application/json; charset=utf-8',
108+
},
109+
keepalive: true,
110+
});
111+
});
112+
113+
it('does not send X-Retry-Count header on first attempt (retryCount=0)', async () => {
114+
const url = 'https://api.segment.io/v1.b';
115+
await sendAnEventPer('KEY', url);
116+
117+
const callArgs = (fetch as jest.Mock).mock.calls[0];
118+
const headers = callArgs[1].headers;
119+
expect(headers['X-Retry-Count']).toBeUndefined();
120+
});
121+
122+
it('sends X-Retry-Count header with provided retry count', async () => {
123+
const url = 'https://api.segment.io/v1.b';
124+
await sendAnEventPer('KEY', url, 5);
125+
126+
expect(fetch).toHaveBeenCalledWith(
127+
url,
128+
expect.objectContaining({
129+
headers: expect.objectContaining({
130+
'X-Retry-Count': '5',
131+
}),
132+
})
133+
);
134+
});
135+
136+
it('sends X-Retry-Count as string format', async () => {
137+
const url = 'https://api.segment.io/v1.b';
138+
await sendAnEventPer('KEY', url, 42);
139+
140+
const callArgs = (fetch as jest.Mock).mock.calls[0];
141+
const headers = callArgs[1].headers;
142+
expect(typeof headers['X-Retry-Count']).toBe('string');
143+
expect(headers['X-Retry-Count']).toBe('42');
91144
});
92145
});

packages/core/src/__tests__/internal/fetchSettings.test.ts

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SegmentClient } from '../../analytics';
2-
import { settingsCDN } from '../../constants';
2+
import { settingsCDN, defaultHttpConfig } from '../../constants';
33
import { SEGMENT_DESTINATION_KEY } from '../../plugins/SegmentDestination';
44
import { getMockLogger, MockSegmentStore } from '../../test-helpers';
55
import { getURL } from '../../util';
@@ -436,6 +436,80 @@ describe('internal #getSettings', () => {
436436
});
437437
});
438438

439+
describe('CDN integrations validation', () => {
440+
it('treats null integrations as empty (no integrations configured)', async () => {
441+
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValueOnce({
442+
ok: true,
443+
json: () => Promise.resolve({ integrations: null }),
444+
status: 200,
445+
} as Response);
446+
447+
const client = new SegmentClient(clientArgs);
448+
await client.fetchSettings();
449+
450+
expect(setSettingsSpy).toHaveBeenCalledWith({});
451+
});
452+
453+
it('treats missing integrations as empty (no integrations configured)', async () => {
454+
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValueOnce({
455+
ok: true,
456+
json: () => Promise.resolve({}),
457+
status: 200,
458+
} as Response);
459+
460+
const client = new SegmentClient(clientArgs);
461+
await client.fetchSettings();
462+
463+
expect(setSettingsSpy).toHaveBeenCalledWith({});
464+
});
465+
466+
it('falls back to defaults when CDN returns integrations as an array', async () => {
467+
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValueOnce({
468+
ok: true,
469+
json: () => Promise.resolve({ integrations: ['invalid'] }),
470+
status: 200,
471+
} as Response);
472+
473+
const client = new SegmentClient(clientArgs);
474+
await client.fetchSettings();
475+
476+
expect(setSettingsSpy).toHaveBeenCalledWith(
477+
defaultIntegrationSettings.integrations
478+
);
479+
});
480+
481+
it('falls back to defaults when CDN returns integrations as a string', async () => {
482+
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValueOnce({
483+
ok: true,
484+
json: () => Promise.resolve({ integrations: 'invalid' }),
485+
status: 200,
486+
} as Response);
487+
488+
const client = new SegmentClient(clientArgs);
489+
await client.fetchSettings();
490+
491+
expect(setSettingsSpy).toHaveBeenCalledWith(
492+
defaultIntegrationSettings.integrations
493+
);
494+
});
495+
496+
it('stores empty integrations when CDN returns null integrations and no defaults', async () => {
497+
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValueOnce({
498+
ok: true,
499+
json: () => Promise.resolve({ integrations: null }),
500+
status: 200,
501+
} as Response);
502+
503+
const client = new SegmentClient({
504+
...clientArgs,
505+
config: { ...clientArgs.config, defaultSettings: undefined },
506+
});
507+
await client.fetchSettings();
508+
509+
expect(setSettingsSpy).toHaveBeenCalledWith({});
510+
});
511+
});
512+
439513
describe('httpConfig extraction', () => {
440514
it('extracts httpConfig from CDN response and merges with defaults', async () => {
441515
const serverHttpConfig = {
@@ -483,7 +557,7 @@ describe('internal #getSettings', () => {
483557
expect(result?.backoffConfig?.jitterPercent).toBe(20);
484558
});
485559

486-
it('returns undefined httpConfig when CDN has no httpConfig', async () => {
560+
it('returns defaultHttpConfig when CDN has no httpConfig', async () => {
487561
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValueOnce({
488562
ok: true,
489563
json: () => Promise.resolve(defaultIntegrationSettings),
@@ -496,7 +570,17 @@ describe('internal #getSettings', () => {
496570
});
497571

498572
await anotherClient.fetchSettings();
499-
expect(anotherClient.getHttpConfig()).toBeUndefined();
573+
const result = anotherClient.getHttpConfig();
574+
expect(result).toBeDefined();
575+
expect(result?.rateLimitConfig?.enabled).toBe(
576+
defaultHttpConfig.rateLimitConfig!.enabled
577+
);
578+
expect(result?.backoffConfig?.enabled).toBe(
579+
defaultHttpConfig.backoffConfig!.enabled
580+
);
581+
expect(result?.backoffConfig?.statusCodeOverrides).toEqual(
582+
defaultHttpConfig.backoffConfig!.statusCodeOverrides
583+
);
500584
});
501585

502586
it('returns undefined httpConfig when fetch fails', async () => {

packages/core/src/analytics.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
defaultFlushAt,
1313
maxPendingEvents,
1414
} from './constants';
15+
import { mergeHttpConfig } from './config-validation';
1516
import { getContext } from './context';
1617
import {
1718
createAliasEvent,
@@ -73,7 +74,7 @@ import {
7374
SegmentError,
7475
translateHTTPError,
7576
} from './errors';
76-
import { validateIntegrations, extractHttpConfig } from './config-validation';
77+
import { validateIntegrations } from './config-validation';
7778
import { QueueFlushingPlugin } from './plugins/QueueFlushingPlugin';
7879
import { WaitingPlugin } from './plugin';
7980

@@ -198,8 +199,8 @@ export class SegmentClient {
198199
}
199200

200201
/**
201-
* Retrieves the server-side httpConfig from CDN settings.
202-
* Returns undefined if the CDN did not provide httpConfig (retry features disabled).
202+
* Retrieves the merged httpConfig (defaultHttpConfig ← CDN ← config overrides).
203+
* Returns undefined only if settings have not yet been fetched.
203204
*/
204205
getHttpConfig(): HttpConfig | undefined {
205206
return this.httpConfig;
@@ -418,8 +419,13 @@ export class SegmentClient {
418419
resJson.middlewareSettings?.routingRules ?? []
419420
);
420421

422+
// Merge httpConfig: defaultHttpConfig ← CDN ← config overrides
423+
this.httpConfig = mergeHttpConfig(
424+
resJson.httpConfig,
425+
this.config.httpConfig,
426+
this.logger
427+
);
421428
if (resJson.httpConfig) {
422-
this.httpConfig = extractHttpConfig(resJson.httpConfig, this.logger);
423429
this.logger.info('Loaded httpConfig from CDN settings.');
424430
}
425431

packages/core/src/api.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,22 @@ export const uploadEvents = async ({
44
writeKey,
55
url,
66
events,
7+
retryCount = 0,
78
}: {
89
writeKey: string;
910
url: string;
1011
events: SegmentEvent[];
12+
retryCount?: number;
1113
}) => {
14+
const headers: Record<string, string> = {
15+
'Content-Type': 'application/json; charset=utf-8',
16+
};
17+
18+
// Only send X-Retry-Count on retries (count > 0), omit on first attempt
19+
if (retryCount > 0) {
20+
headers['X-Retry-Count'] = retryCount.toString();
21+
}
22+
1223
return await fetch(url, {
1324
method: 'POST',
1425
keepalive: true,
@@ -17,8 +28,6 @@ export const uploadEvents = async ({
1728
sentAt: new Date().toISOString(),
1829
writeKey: writeKey,
1930
}),
20-
headers: {
21-
'Content-Type': 'application/json; charset=utf-8',
22-
},
31+
headers,
2332
});
2433
};

packages/core/src/backoff/RetryManager.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,6 @@ export class RetryManager {
126126
return true;
127127
}
128128

129-
if (!this.isPersistedStateValid(state, now)) {
130-
this.logger?.warn(
131-
'Persisted retry state failed validation, resetting to READY'
132-
);
133-
await this.reset();
134-
return true;
135-
}
136-
137129
if (now >= state.waitUntilTime) {
138130
await this.transitionToReady();
139131
return true;

0 commit comments

Comments
 (0)