fix: Improve media_query retry behavior (#2603)
This commit is contained in:
@@ -771,6 +771,57 @@ describe('IssueManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('in-flight retry', () => {
|
||||
it('should hold the backoff and not arm a timer while a retry is in flight', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
const api = createCardAPI();
|
||||
const config = createConfig();
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue({
|
||||
...config,
|
||||
view: {
|
||||
...config.view,
|
||||
issues: { interaction_mode: 'all', retry_seconds: 'auto' },
|
||||
},
|
||||
});
|
||||
const manager = new IssueManager(api);
|
||||
|
||||
const canRetryNow = vi.fn().mockReturnValue(true);
|
||||
const issue = createIssue('media_query', {
|
||||
hasIssue: vi.fn().mockReturnValue(true),
|
||||
needsRetry: vi.fn().mockReturnValue(true),
|
||||
canRetryNow,
|
||||
retry: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
manager.addIssue(issue);
|
||||
|
||||
manager.evaluate();
|
||||
|
||||
// First attempt fires at the base delay, advancing the backoff to
|
||||
// attempt 1.
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
|
||||
expect(issue.retry).toBeCalledTimes(1);
|
||||
|
||||
// The attempt is now in flight: the problem is still unresolved
|
||||
// (needsRetry) but cannot be retried right now (canRetryNow). The running
|
||||
// timer is cancelled and no further attempt fires, however long we wait.
|
||||
canRetryNow.mockReturnValue(false);
|
||||
manager.evaluate();
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000);
|
||||
expect(issue.retry).toBeCalledTimes(1);
|
||||
|
||||
// The attempt fails and becomes retryable again. Because the backoff was
|
||||
// preserved, the next delay is the attempt-1 step (base*2), not base.
|
||||
canRetryNow.mockReturnValue(true);
|
||||
manager.evaluate();
|
||||
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
|
||||
expect(issue.retry).toBeCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 0.75 * 1000);
|
||||
expect(issue.retry).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset a specific issue and re-evaluate', () => {
|
||||
const api = createCardAPI();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CardController } from '../../../../src/card-controller/controller';
|
||||
import { MediaQueryIssue } from '../../../../src/card-controller/issues/issues/media-query';
|
||||
import type { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal';
|
||||
import { createCardAPI } from '../../../test-utils';
|
||||
import { createCardAPI, flushPromises } from '../../../test-utils';
|
||||
|
||||
const createIssue = (): {
|
||||
issue: MediaQueryIssue;
|
||||
@@ -106,26 +106,122 @@ describe('MediaQueryIssue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('canRetryNow', () => {
|
||||
it('should return false when not triggered', () => {
|
||||
const { issue } = createIssue();
|
||||
|
||||
expect(issue.canRetryNow()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when triggered but false while a retry is in flight', () => {
|
||||
const { issue } = createIssue();
|
||||
issue.trigger({ error: new Error('query failed') });
|
||||
|
||||
expect(issue.canRetryNow()).toBe(true);
|
||||
|
||||
issue.retry();
|
||||
|
||||
expect(issue.canRetryNow()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry', () => {
|
||||
it('should return requery action and clear error and needsRetry', () => {
|
||||
it('should re-run the query with the retry intent and keep the error present', () => {
|
||||
const { issue, api } = createIssue();
|
||||
issue.trigger({ error: new Error('query failed') });
|
||||
|
||||
const result = issue.retry();
|
||||
|
||||
expect(result).toEqual(true);
|
||||
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalled();
|
||||
expect(issue.needsRetry()).toBe(false);
|
||||
expect(issue.hasIssue()).toBe(false);
|
||||
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
|
||||
intent: 'retry',
|
||||
});
|
||||
|
||||
// The error persists across the retry so the issue stays visible and the
|
||||
// problem stays unresolved, but no further retry can be dispatched until
|
||||
// this in-flight attempt resolves.
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
expect(issue.needsRetry()).toBe(true);
|
||||
expect(issue.canRetryNow()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return null when needsRetry is false', () => {
|
||||
it('should allow a retry again once a fresh failure is reported via trigger', () => {
|
||||
const { issue } = createIssue();
|
||||
issue.trigger({ error: new Error('query failed') });
|
||||
issue.retry();
|
||||
expect(issue.canRetryNow()).toBe(false);
|
||||
|
||||
// The failure is reported out-of-band via trigger (the normal path), which
|
||||
// ends the in-flight attempt.
|
||||
issue.trigger({ error: new Error('query failed again') });
|
||||
|
||||
expect(issue.canRetryNow()).toBe(true);
|
||||
});
|
||||
|
||||
it('should fully resolve when the in-flight attempt succeeds', () => {
|
||||
const { issue } = createIssue();
|
||||
issue.trigger({ error: new Error('query failed') });
|
||||
issue.retry();
|
||||
|
||||
// A success clears the issue via reset.
|
||||
issue.reset();
|
||||
|
||||
expect(issue.hasIssue()).toBe(false);
|
||||
expect(issue.needsRetry()).toBe(false);
|
||||
expect(issue.canRetryNow()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return null when there is no error to retry', () => {
|
||||
const { issue } = createIssue();
|
||||
|
||||
const result = issue.retry();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not dispatch a second query when a retry is already in flight', () => {
|
||||
const { issue, api } = createIssue();
|
||||
issue.trigger({ error: new Error('query failed') });
|
||||
|
||||
issue.retry();
|
||||
// A forced retry (e.g. the user tapping retry) while the first is in
|
||||
// flight must not start a competing query.
|
||||
const result = issue.retry();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop treating the retry as in flight once the query settles without an outcome', async () => {
|
||||
const { issue, api } = createIssue();
|
||||
vi.mocked(
|
||||
api.getViewManager().setViewByParametersWithNewQuery,
|
||||
).mockResolvedValue();
|
||||
issue.trigger({ error: new Error('query failed') });
|
||||
|
||||
issue.retry();
|
||||
expect(issue.canRetryNow()).toBe(false);
|
||||
|
||||
// The query resolved without reporting a trigger/reset outcome; the
|
||||
// in-flight flag must still clear so a further retry can be dispatched.
|
||||
await flushPromises();
|
||||
|
||||
expect(issue.canRetryNow()).toBe(true);
|
||||
});
|
||||
|
||||
it('should stop treating the retry as in flight when the query rejects', async () => {
|
||||
const { issue, api } = createIssue();
|
||||
vi.mocked(api.getViewManager().setViewByParametersWithNewQuery).mockRejectedValue(
|
||||
new Error('boom'),
|
||||
);
|
||||
issue.trigger({ error: new Error('query failed') });
|
||||
|
||||
issue.retry();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(issue.canRetryNow()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear the issue after reset', () => {
|
||||
|
||||
@@ -221,6 +221,30 @@ describe('IssueStateManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('canRetryNow', () => {
|
||||
it('should return false when no issue can retry now', () => {
|
||||
expect(createManager().canRetryNow()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when an issue reports it can retry now', () => {
|
||||
assert(mockMediaLoad.canRetryNow);
|
||||
vi.mocked(mockMediaLoad.canRetryNow).mockReturnValue(true);
|
||||
|
||||
expect(createManager().canRetryNow()).toBe(true);
|
||||
});
|
||||
|
||||
it('should fall back to needsRetry for an issue that does not implement canRetryNow', () => {
|
||||
const issue: Issue = {
|
||||
key: 'media_query',
|
||||
hasIssue: () => true,
|
||||
getIssue: () => null,
|
||||
needsRetry: () => true,
|
||||
};
|
||||
|
||||
expect(createManager([issue]).canRetryNow()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry', () => {
|
||||
it('should call retry on issues that want retry with non-exclusive result', () => {
|
||||
assert(mockMediaLoad.needsRetry);
|
||||
|
||||
@@ -579,6 +579,31 @@ describe('should handle exceptions', () => {
|
||||
.mock.calls.filter(([key]) => key === 'media_query').length,
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('should not reset media_query on the retry dispatch, keeping the error visible', async () => {
|
||||
const error = new Error();
|
||||
const viewFactory = mock<ViewFactory>();
|
||||
viewFactory.getViewByParameters.mockReturnValue(createView());
|
||||
const viewQueryExecutor = mock<ViewQueryExecutor>();
|
||||
viewQueryExecutor.getNewQueryModifiers.mockRejectedValue(error);
|
||||
|
||||
const api = createInitializedCardAPI();
|
||||
const manager = new ViewManager(api, {
|
||||
viewFactory,
|
||||
viewQueryExecutor,
|
||||
});
|
||||
|
||||
await manager.setViewByParametersWithNewQuery({ intent: 'retry' });
|
||||
|
||||
// A retry re-runs the same failing query, so the existing error must not be
|
||||
// cleared up front (it would blink away and back); the failure just
|
||||
// re-triggers it.
|
||||
expect(api.getIssueManager().reset).not.toBeCalledWith('media_query');
|
||||
expect(api.getIssueManager().trigger).toBeCalledWith(
|
||||
'media_query',
|
||||
expect.objectContaining({ error }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasMajorMediaChange', () => {
|
||||
|
||||
Reference in New Issue
Block a user