more rate-limit aware posts and deletes

This commit is contained in:
2025-09-29 11:58:11 -06:00
parent 33120c40a5
commit 2e474cb43a
7 changed files with 68 additions and 42 deletions

View File

@@ -1,7 +1,7 @@
import { canvasApi, paginatedRequest } from "./canvasServiceUtils"; import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { CanvasAssignmentGroup } from "@/features/canvas/models/assignments/canvasAssignmentGroup"; import { CanvasAssignmentGroup } from "@/features/canvas/models/assignments/canvasAssignmentGroup";
import { LocalAssignmentGroup } from "@/features/local/assignments/models/localAssignmentGroup"; import { LocalAssignmentGroup } from "@/features/local/assignments/models/localAssignmentGroup";
import { rateLimitAwareDelete } from "./canvasWebRequestor"; import { rateLimitAwareDelete, rateLimitAwarePost } from "./canvasWebRequestUtils";
import { axiosClient } from "@/services/axiosUtils"; import { axiosClient } from "@/services/axiosUtils";
export const canvasAssignmentGroupService = { export const canvasAssignmentGroupService = {
@@ -26,7 +26,7 @@ export const canvasAssignmentGroupService = {
}; };
const { data: canvasAssignmentGroup } = const { data: canvasAssignmentGroup } =
await axiosClient.post<CanvasAssignmentGroup>(url, body); await rateLimitAwarePost<CanvasAssignmentGroup>(url, body);
return { return {
...localAssignmentGroup, ...localAssignmentGroup,

View File

@@ -8,6 +8,7 @@ import { getRubricCriterion } from "./canvasRubricUtils";
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings"; import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
import { axiosClient } from "@/services/axiosUtils"; import { axiosClient } from "@/services/axiosUtils";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils"; import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
import { rateLimitAwarePost } from "./canvasWebRequestUtils";
export const canvasAssignmentService = { export const canvasAssignmentService = {
async getAll(courseId: number): Promise<CanvasAssignment[]> { async getAll(courseId: number): Promise<CanvasAssignment[]> {
@@ -60,7 +61,7 @@ export const canvasAssignmentService = {
}, },
}; };
const response = await axiosClient.post<CanvasAssignment>(url, body); const response = await rateLimitAwarePost<CanvasAssignment>(url, body);
const canvasAssignment = response.data; const canvasAssignment = response.data;
await createRubric(canvasCourseId, canvasAssignment.id, localAssignment); await createRubric(canvasCourseId, canvasAssignment.id, localAssignment);
@@ -152,7 +153,7 @@ const createRubric = async (
}; };
const rubricUrl = `${canvasApi}/courses/${courseId}/rubrics`; const rubricUrl = `${canvasApi}/courses/${courseId}/rubrics`;
const rubricResponse = await axiosClient.post<CanvasRubricCreationResponse>( const rubricResponse = await rateLimitAwarePost<CanvasRubricCreationResponse>(
rubricUrl, rubricUrl,
rubricBody rubricBody
); );

View File

@@ -3,6 +3,7 @@ import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel";
import { canvasApi, paginatedRequest } from "./canvasServiceUtils"; import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { CanvasModule } from "@/features/canvas/models/modules/canvasModule"; import { CanvasModule } from "@/features/canvas/models/modules/canvasModule";
import { axiosClient } from "@/services/axiosUtils"; import { axiosClient } from "@/services/axiosUtils";
import { rateLimitAwarePost } from "./canvasWebRequestUtils";
export const canvasModuleService = { export const canvasModuleService = {
async updateModuleItem( async updateModuleItem(
@@ -37,7 +38,7 @@ export const canvasModuleService = {
console.log(`Creating new module item ${title}`); console.log(`Creating new module item ${title}`);
const url = `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`; const url = `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`;
const body = { module_item: { title, type, content_id: contentId } }; const body = { module_item: { title, type, content_id: contentId } };
await axiosClient.post(url, body); await rateLimitAwarePost(url, body);
}, },
async createPageModuleItem( async createPageModuleItem(
@@ -51,7 +52,7 @@ export const canvasModuleService = {
const body = { const body = {
module_item: { title, type: "Page", page_url: canvasPage.url }, module_item: { title, type: "Page", page_url: canvasPage.url },
}; };
await axiosClient.post<CanvasModuleItem>(url, body); await rateLimitAwarePost<CanvasModuleItem>(url, body);
}, },
async getCourseModules(canvasCourseId: number) { async getCourseModules(canvasCourseId: number) {
@@ -67,7 +68,7 @@ export const canvasModuleService = {
name: moduleName, name: moduleName,
}, },
}; };
const response = await axiosClient.post<CanvasModule>(url, body); const response = await rateLimitAwarePost<CanvasModule>(url, body);
return response.data.id; return response.data.id;
}, },

View File

@@ -1,7 +1,7 @@
import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel"; import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel";
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels"; import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import { canvasApi, paginatedRequest } from "./canvasServiceUtils"; import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { rateLimitAwareDelete } from "./canvasWebRequestor"; import { rateLimitAwareDelete, rateLimitAwarePost } from "./canvasWebRequestUtils";
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings"; import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
import { axiosClient } from "@/services/axiosUtils"; import { axiosClient } from "@/services/axiosUtils";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils"; import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
@@ -41,7 +41,7 @@ export const canvasPageService = {
}, },
}; };
const { data: canvasPage } = await axiosClient.post<CanvasPage>(url, body); const { data: canvasPage } = await rateLimitAwarePost<CanvasPage>(url, body);
if (!canvasPage) { if (!canvasPage) {
throw new Error("Created canvas course page was null"); throw new Error("Created canvas course page was null");
} }

View File

@@ -12,6 +12,7 @@ import { LocalCourseSettings } from "@/features/local/course/localCourseSettings
import { axiosClient } from "@/services/axiosUtils"; import { axiosClient } from "@/services/axiosUtils";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils"; import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
import { escapeMatchingText } from "@/services/utils/questionHtmlUtils"; import { escapeMatchingText } from "@/services/utils/questionHtmlUtils";
import { rateLimitAwareDelete, rateLimitAwarePost } from "./canvasWebRequestUtils";
export const getAnswers = ( export const getAnswers = (
question: LocalQuizQuestion, question: LocalQuizQuestion,
@@ -64,7 +65,7 @@ const createQuestionOnly = async (
}, },
}; };
const response = await axiosClient.post<CanvasQuizQuestion>(url, body); const response = await rateLimitAwarePost<CanvasQuizQuestion>(url, body);
const newQuestion = response.data; const newQuestion = response.data;
if (!newQuestion) throw new Error("Created question is null"); if (!newQuestion) throw new Error("Created question is null");
@@ -86,7 +87,7 @@ const hackFixQuestionOrdering = async (
})); }));
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/reorder`; const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/reorder`;
await axiosClient.post(url, { order }); await rateLimitAwarePost(url, { order });
}; };
const verifyQuestionOrder = async ( const verifyQuestionOrder = async (
@@ -95,7 +96,7 @@ const verifyQuestionOrder = async (
localQuiz: LocalQuiz localQuiz: LocalQuiz
): Promise<boolean> => { ): Promise<boolean> => {
console.log("Verifying question order in Canvas quiz"); console.log("Verifying question order in Canvas quiz");
try { try {
const canvasQuestions = await canvasQuizService.getQuizQuestions( const canvasQuestions = await canvasQuizService.getQuizQuestions(
canvasCourseId, canvasCourseId,
@@ -113,19 +114,21 @@ const verifyQuestionOrder = async (
// Verify that questions are in the correct order by comparing text content // Verify that questions are in the correct order by comparing text content
// We'll use a simple approach: strip HTML tags and compare the core text content // We'll use a simple approach: strip HTML tags and compare the core text content
const stripHtml = (html: string): string => { const stripHtml = (html: string): string => {
return html.replace(/<[^>]*>/g, '').trim(); return html.replace(/<[^>]*>/g, "").trim();
}; };
for (let i = 0; i < localQuiz.questions.length; i++) { for (let i = 0; i < localQuiz.questions.length; i++) {
const localQuestion = localQuiz.questions[i]; const localQuestion = localQuiz.questions[i];
const canvasQuestion = canvasQuestions[i]; const canvasQuestion = canvasQuestions[i];
const localQuestionText = localQuestion.text.trim(); const localQuestionText = localQuestion.text.trim();
const canvasQuestionText = stripHtml(canvasQuestion.question_text).trim(); const canvasQuestionText = stripHtml(canvasQuestion.question_text).trim();
// Check if the question text content matches (allowing for HTML conversion differences) // Check if the question text content matches (allowing for HTML conversion differences)
if (!canvasQuestionText.includes(localQuestionText) && if (
!localQuestionText.includes(canvasQuestionText)) { !canvasQuestionText.includes(localQuestionText) &&
!localQuestionText.includes(canvasQuestionText)
) {
console.error( console.error(
`Question order mismatch at position ${i}:`, `Question order mismatch at position ${i}:`,
`Local: "${localQuestionText}"`, `Local: "${localQuestionText}"`,
@@ -135,9 +138,14 @@ const verifyQuestionOrder = async (
} }
// Verify position is correct // Verify position is correct
if (canvasQuestion.position !== undefined && canvasQuestion.position !== i + 1) { if (
canvasQuestion.position !== undefined &&
canvasQuestion.position !== i + 1
) {
console.error( console.error(
`Question position mismatch at index ${i}: Canvas position is ${canvasQuestion.position}, expected ${i + 1}` `Question position mismatch at index ${i}: Canvas position is ${
canvasQuestion.position
}, expected ${i + 1}`
); );
return false; return false;
} }
@@ -294,7 +302,10 @@ export const canvasQuizService = {
}, },
}; };
const { data: canvasQuiz } = await axiosClient.post<CanvasQuiz>(url, body); const { data: canvasQuiz } = await rateLimitAwarePost<CanvasQuiz>(
url,
body
);
await createQuizQuestions( await createQuizQuestions(
canvasCourseId, canvasCourseId,
canvasQuiz.id, canvasQuiz.id,
@@ -305,6 +316,6 @@ export const canvasQuizService = {
}, },
async delete(canvasCourseId: number, canvasQuizId: number) { async delete(canvasCourseId: number, canvasQuizId: number) {
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}`; const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}`;
await axiosClient.delete(url); await rateLimitAwareDelete(url);
}, },
}; };

View File

@@ -1,5 +1,5 @@
import { axiosClient } from "@/services/axiosUtils"; import { axiosClient } from "@/services/axiosUtils";
import { AxiosResponse } from "axios"; import { AxiosResponse, AxiosRequestConfig } from "axios";
const rateLimitRetryCount = 6; const rateLimitRetryCount = 6;
const rateLimitSleepInterval = 1000; const rateLimitSleepInterval = 1000;
@@ -16,21 +16,26 @@ export const isRateLimited = async (
); );
}; };
// const rateLimitAwarePost = async (url: string, body: unknown, retryCount = 0) => { export const rateLimitAwarePost = async <T>(
// const response = await axiosClient.post(url, body); url: string,
body: unknown,
config?: AxiosRequestConfig,
retryCount = 0
): Promise<AxiosResponse<T>> => {
const response = await axiosClient.post<T>(url, body, config);
// if (await isRateLimited(response)) { if (await isRateLimited(response)) {
// if (retryCount < rateLimitRetryCount) { if (retryCount < rateLimitRetryCount) {
// console.info( console.info(
// `Hit rate limit on post, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying` `Hit rate limit on post, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying`
// ); );
// await sleep(rateLimitSleepInterval); await sleep(rateLimitSleepInterval);
// return await rateLimitAwarePost(url, body, retryCount + 1); return await rateLimitAwarePost<T>(url, body, config, retryCount + 1);
// } }
// } }
// return response; return response;
// }; };
export const rateLimitAwareDelete = async ( export const rateLimitAwareDelete = async (
url: string, url: string,

View File

@@ -4,10 +4,11 @@ import axios from "axios";
import { canvasApi } from "../canvasServiceUtils"; import { canvasApi } from "../canvasServiceUtils";
import { axiosClient } from "@/services/axiosUtils"; import { axiosClient } from "@/services/axiosUtils";
import FormData from "form-data"; import FormData from "form-data";
import { rateLimitAwarePost } from "../canvasWebRequestUtils";
export const downloadUrlToTempDirectory = async ( export const downloadUrlToTempDirectory = async (
sourceUrl: string sourceUrl: string
): Promise<{fileName: string, success: boolean}> => { ): Promise<{ fileName: string; success: boolean }> => {
try { try {
const fileName = const fileName =
path.basename(new URL(sourceUrl).pathname) || `tempfile-${Date.now()}`; path.basename(new URL(sourceUrl).pathname) || `tempfile-${Date.now()}`;
@@ -16,10 +17,10 @@ export const downloadUrlToTempDirectory = async (
responseType: "arraybuffer", responseType: "arraybuffer",
}); });
await fs.writeFile(tempFilePath, response.data); await fs.writeFile(tempFilePath, response.data);
return {fileName: tempFilePath, success: true}; return { fileName: tempFilePath, success: true };
} catch (error) { } catch (error) {
console.log("Error downloading or saving the file:", sourceUrl, error); console.log("Error downloading or saving the file:", sourceUrl, error);
return {fileName: sourceUrl, success: false}; return { fileName: sourceUrl, success: false };
} }
}; };
@@ -45,7 +46,10 @@ export const uploadToCanvasPart1 = async (
formData.append("name", path.basename(pathToUpload)); formData.append("name", path.basename(pathToUpload));
formData.append("size", (await getFileSize(pathToUpload)).toString()); formData.append("size", (await getFileSize(pathToUpload)).toString());
const response = await axiosClient.post(url, formData); const response = await rateLimitAwarePost<{
upload_url: string;
upload_params: string;
}>(url, formData);
const upload_url = response.data.upload_url; const upload_url = response.data.upload_url;
const upload_params = response.data.upload_params; const upload_params = response.data.upload_params;
@@ -77,10 +81,14 @@ export const uploadToCanvasPart2 = async ({
const fileName = path.basename(pathToUpload); const fileName = path.basename(pathToUpload);
formData.append("file", fileBuffer, fileName); formData.append("file", fileBuffer, fileName);
const response = await axiosClient.post(upload_url, formData, { const response = await rateLimitAwarePost<{ url: string }>(
headers: formData.getHeaders(), upload_url,
validateStatus: (status) => status < 500, formData,
}); {
headers: formData.getHeaders(),
validateStatus: (status) => status < 500,
}
);
if (response.status === 301) { if (response.status === 301) {
const redirectUrl = response.headers.location; const redirectUrl = response.headers.location;