diff --git a/src/CourseAuthoringPage.test.jsx b/src/CourseAuthoringPage.test.jsx
index 6122056c23..e3ca1f314f 100644
--- a/src/CourseAuthoringPage.test.jsx
+++ b/src/CourseAuthoringPage.test.jsx
@@ -4,7 +4,7 @@ import CourseAuthoringPage from './CourseAuthoringPage';
import PagesAndResources from './pages-and-resources/PagesAndResources';
import { executeThunk } from './utils';
import { fetchCourseApps } from './pages-and-resources/data/thunks';
-import { fetchCourseDetail } from './data/thunks';
+import { fetchCourseDetail, retryConfig } from './data/thunks';
import { getApiWaffleFlagsUrl } from './data/api';
import { initializeMocks, render } from './testUtils';
@@ -43,8 +43,7 @@ describe('Editor Pages Load no header', () => {
const wrapper = render(
-
- ,
+ ,
);
expect(wrapper.queryByRole('status')).not.toBeInTheDocument();
});
@@ -54,8 +53,7 @@ describe('Editor Pages Load no header', () => {
const wrapper = render(
-
- ,
+ ,
);
expect(wrapper.queryByRole('status')).toBeInTheDocument();
});
@@ -64,6 +62,15 @@ describe('Editor Pages Load no header', () => {
describe('Course authoring page', () => {
const lmsApiBaseUrl = getConfig().LMS_BASE_URL;
const courseDetailApiUrl = `${lmsApiBaseUrl}/api/courses/v1/courses`;
+
+ beforeAll(() => {
+ retryConfig.enabled = false;
+ });
+
+ afterAll(() => {
+ retryConfig.enabled = true;
+ });
+
const mockStoreNotFound = async () => {
axiosMock.onGet(
`${courseDetailApiUrl}/${courseId}?username=abc123`,
@@ -72,6 +79,7 @@ describe('Course authoring page', () => {
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
+
const mockStoreError = async () => {
axiosMock.onGet(
`${courseDetailApiUrl}/${courseId}?username=abc123`,
@@ -80,11 +88,13 @@ describe('Course authoring page', () => {
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
+
test('renders not found page on non-existent course key', async () => {
await mockStoreNotFound();
const wrapper = render();
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
});
+
test('does not render not found page on other kinds of error', async () => {
await mockStoreError();
// Currently, loading errors are not handled, so we wait for the child
@@ -95,12 +105,12 @@ describe('Course authoring page', () => {
const wrapper = render(
-
- ,
+ ,
);
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument();
});
+
const mockStoreDenied = async () => {
const studioApiBaseUrl = getConfig().STUDIO_BASE_URL;
const courseAppsApiUrl = `${studioApiBaseUrl}/api/course_apps/v1/apps`;
@@ -110,6 +120,7 @@ describe('Course authoring page', () => {
).reply(403);
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};
+
test('renders PermissionDeniedAlert when courseAppsApiStatus is DENIED', async () => {
mockPathname = '/editor/';
await mockStoreDenied();
@@ -118,3 +129,103 @@ describe('Course authoring page', () => {
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
});
+
+// New test suite for retry logic
+describe('fetchCourseDetail retry logic', () => {
+ const lmsApiBaseUrl = getConfig().LMS_BASE_URL;
+ const courseDetailApiUrl = `${lmsApiBaseUrl}/api/courses/v1/courses`;
+
+ beforeAll(() => {
+ retryConfig.enabled = true;
+ retryConfig.maxRetries = 3;
+ retryConfig.initialDelay = 10;
+ });
+
+ afterAll(() => {
+ retryConfig.enabled = false;
+ });
+
+ test('retries on 404 and eventually succeeds', async () => {
+ const courseDetail = {
+ id: courseId,
+ name: 'Test Course',
+ start: new Date().toISOString(),
+ };
+
+ axiosMock
+ .onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
+ .replyOnce(404)
+ .onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
+ .replyOnce(200, courseDetail);
+
+ await executeThunk(fetchCourseDetail(courseId), store.dispatch);
+
+ const state = store.getState();
+ expect(state.courseDetail.courseId).toBe(courseId);
+ expect(state.courseDetail.status).toBe('successful');
+ });
+
+ test('retries on 202 and eventually succeeds', async () => {
+ const courseDetail = {
+ id: courseId,
+ name: 'Test Course',
+ start: new Date().toISOString(),
+ };
+
+ axiosMock
+ .onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
+ .replyOnce(202, { error: 'course_not_ready' })
+ .onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
+ .replyOnce(200, courseDetail);
+
+ await executeThunk(fetchCourseDetail(courseId), store.dispatch);
+
+ const state = store.getState();
+ expect(state.courseDetail.status).toBe('successful');
+ });
+
+ test('gives up after max retries on persistent 404', async () => {
+ axiosMock
+ .onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
+ .reply(404);
+
+ await executeThunk(fetchCourseDetail(courseId), store.dispatch);
+
+ const state = store.getState();
+ expect(state.courseDetail.status).toBe('not-found');
+ });
+
+ test('does not retry on 500 errors', async () => {
+ axiosMock
+ .onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
+ .reply(500);
+
+ await executeThunk(fetchCourseDetail(courseId), store.dispatch);
+
+ const state = store.getState();
+ expect(state.courseDetail.status).toBe('failed');
+
+ expect(axiosMock.history.get.filter(
+ req => req.url.includes(courseId),
+ ).length).toBe(1);
+ });
+
+ test('respects retryConfig.enabled flag', async () => {
+ retryConfig.enabled = false;
+
+ axiosMock
+ .onGet(`${courseDetailApiUrl}/${courseId}?username=abc123`)
+ .reply(404);
+
+ await executeThunk(fetchCourseDetail(courseId), store.dispatch);
+
+ const state = store.getState();
+ expect(state.courseDetail.status).toBe('not-found');
+
+ expect(axiosMock.history.get.filter(
+ req => req.url.includes(courseId),
+ ).length).toBe(1);
+
+ retryConfig.enabled = true;
+ });
+});
diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx
index d5003f9eab..6492d28a58 100644
--- a/src/course-outline/CourseOutline.test.tsx
+++ b/src/course-outline/CourseOutline.test.tsx
@@ -27,6 +27,7 @@ import {
getXBlockBaseApiUrl,
exportTags,
createDiscussionsTopicsUrl,
+ getCourseOutlineIndex,
} from './data/api';
import {
fetchCourseBestPracticesQuery,
@@ -132,6 +133,10 @@ jest.mock('@src/studio-home/data/selectors', () => ({
}),
}));
+jest.mock('./data/api', () => ({
+ getCourseOutlineIndex: jest.fn(),
+}));
+
// eslint-disable-next-line no-promise-executor-return
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -2484,3 +2489,52 @@ describe('', () => {
expect(axiosMock.history.delete[0].url).toBe(getDownstreamApiUrl(courseSectionMock.id));
});
});
+
+describe('retryOnNotReady lightweight coverage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+ });
+
+ it('retries on 202 then succeeds', async () => {
+ const mockDispatch = jest.fn();
+
+ (getCourseOutlineIndex as jest.Mock)
+ .mockRejectedValueOnce({ response: { status: 202 } })
+ .mockRejectedValueOnce({ response: { status: 404 } })
+ .mockResolvedValueOnce({
+ courseReleaseDate: '2025-10-29',
+ courseStructure: {
+ highlightsEnabledForMessaging: true,
+ videoSharingEnabled: true,
+ videoSharingOptions: [],
+ actions: {},
+ },
+ });
+
+ const thunk = fetchCourseOutlineIndexQuery(courseId);
+ const promise = thunk(mockDispatch);
+
+ jest.runAllTimers();
+ await promise;
+
+ expect(getCourseOutlineIndex).toHaveBeenCalledTimes(3);
+ // Verifica que terminó con éxito
+ expect(mockDispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.objectContaining({ status: RequestStatus.SUCCESSFUL }),
+ }),
+ );
+ });
+
+ it('throws after max retries', async () => {
+ const mockDispatch = jest.fn();
+ (getCourseOutlineIndex as jest.Mock).mockRejectedValue({ response: { status: 202 } });
+
+ const thunk = fetchCourseOutlineIndexQuery(courseId);
+ const promise = thunk(mockDispatch);
+
+ jest.runAllTimers();
+ await expect(promise).rejects.toBeDefined();
+ });
+});
diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts
index 71157d11c1..d4cfdcda2e 100644
--- a/src/course-outline/data/thunk.ts
+++ b/src/course-outline/data/thunk.ts
@@ -56,6 +56,40 @@ import {
updateCourseLaunchQueryStatus,
} from './slice';
+/**
+ * Action to fetch course outline.
+ *
+ * @param {string} courseId - ID of the course
+ * @returns {Object} - Object containing fetch course outline index query success or failure status
+ */
+
+/**
+ * Helper function to retry API calls when course is not ready yet
+ */
+async function retryOnNotReady(
+ apiCall: () => Promise,
+ maxRetries: number = 5,
+ delayMs: number = 2000,
+ attempt: number = 1,
+): Promise {
+ try {
+ return await apiCall();
+ } catch (error: any) {
+ const isNotReady = error?.response?.status === 202
+ || error?.response?.status === 404;
+ const canRetry = attempt < maxRetries;
+
+ if (isNotReady && canRetry) {
+ await new Promise((resolve) => {
+ setTimeout(resolve, delayMs);
+ });
+ return retryOnNotReady(apiCall, maxRetries, delayMs, attempt + 1);
+ }
+
+ throw error;
+ }
+}
+
/**
* Action to fetch course outline.
*
@@ -67,7 +101,8 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any)
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
- const outlineIndex = await getCourseOutlineIndex(courseId);
+ const outlineIndex = await retryOnNotReady(() => getCourseOutlineIndex(courseId));
+
const {
courseReleaseDate,
courseStructure: {
@@ -77,6 +112,7 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any)
actions,
},
} = outlineIndex;
+
dispatch(fetchOutlineIndexSuccess(outlineIndex));
dispatch(updateStatusBar({
courseReleaseDate,
@@ -85,7 +121,6 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any)
videoSharingEnabled,
}));
dispatch(updateCourseActions(actions));
-
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error: any) {
if (error.response && error.response.status === 403) {
diff --git a/src/data/thunks.ts b/src/data/thunks.ts
index bbfc86340b..1d0901ca45 100644
--- a/src/data/thunks.ts
+++ b/src/data/thunks.ts
@@ -7,20 +7,76 @@ import {
} from './slice';
import { RequestStatus } from './constants';
-export function fetchCourseDetail(courseId) {
- return async (dispatch) => {
+/**
+ * Retry configuration - can be overridden in tests
+ */
+export const retryConfig = {
+ maxRetries: 10,
+ initialDelay: 2000,
+ backoffMultiplier: 1.5,
+ enabled: true,
+};
+
+/**
+ * Retry an API call if the course is not ready yet (202 or 404 status).
+ * Uses exponential backoff for retries.
+ */
+async function retryOnNotReady(
+ apiCall: () => Promise,
+ maxRetries: number = retryConfig.maxRetries,
+ delayMs: number = retryConfig.initialDelay,
+ attempt: number = 1,
+ backoffMultiplier: number = retryConfig.backoffMultiplier,
+): Promise {
+ // Skip retries if disabled (useful for tests)
+ if (!retryConfig.enabled) {
+ return apiCall();
+ }
+
+ try {
+ return await apiCall();
+ } catch (error: any) {
+ const isNotReady = error?.response?.status === 202
+ || error?.response?.status === 404;
+ const canRetry = attempt < maxRetries;
+
+ if (isNotReady && canRetry) {
+ const nextDelay = delayMs * backoffMultiplier ** (attempt - 1);
+
+ await new Promise((resolve) => {
+ setTimeout(resolve, nextDelay);
+ });
+
+ return retryOnNotReady(
+ apiCall,
+ maxRetries,
+ delayMs,
+ attempt + 1,
+ backoffMultiplier,
+ );
+ }
+
+ throw error;
+ }
+}
+
+export function fetchCourseDetail(courseId: string) {
+ return async (dispatch: any) => {
dispatch(updateStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
try {
- const courseDetail = await getCourseDetail(courseId, getAuthenticatedUser().username);
- dispatch(updateStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
+ const courseDetail = await retryOnNotReady(
+ () => getCourseDetail(courseId, getAuthenticatedUser().username),
+ );
+ dispatch(updateStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
dispatch(addModel({ modelType: 'courseDetails', model: courseDetail }));
dispatch(updateCanChangeProviders({
- canChangeProviders: getAuthenticatedUser().administrator || new Date(courseDetail.start) > new Date(),
+ canChangeProviders: getAuthenticatedUser().administrator
+ || new Date(courseDetail.start) > new Date(),
}));
- } catch (error) {
- if ((error as any).response && (error as any).response.status === 404) {
+ } catch (error: any) {
+ if (error?.response?.status === 404) {
dispatch(updateStatus({ courseId, status: RequestStatus.NOT_FOUND }));
} else {
dispatch(updateStatus({ courseId, status: RequestStatus.FAILED }));