Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/app-store/googlecalendar/lib/CalendarAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ import { getGoogleAppKeys } from "./getGoogleAppKeys";
type DelegatedTo = NonNullable<CredentialForCalendarServiceWithEmail["delegatedTo"]>;
const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarAuth"] });

// gaxios (the HTTP client behind googleapis) does not retry PATCH or HTTP 403 by default. Creating a
// Google Calendar event does an insert (POST) followed by a PATCH to add description/location/
// conferenceData, and Google returns 403 for rateLimitExceeded
// (https://developers.google.com/workspace/calendar/api/guides/errors). A transient 403 on that PATCH
// was therefore never retried, silently desyncing the calendar event from the booking. PATCH is
// idempotent so retrying it is safe; POST/insert is intentionally left out to avoid duplicate events.
// 408 (request timeout) is also included as a transient, retryable status alongside gaxios's defaults.
const GOOGLE_CALENDAR_RETRY_CONFIG = {
retry: 3,
httpMethodsToRetry: ["GET", "HEAD", "PUT", "OPTIONS", "DELETE", "PATCH"],
statusCodesToRetry: [
[100, 199],
[403, 403],
[408, 408],
[429, 429],
[500, 599],
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

class MyGoogleOAuth2Client extends OAuth2Client {
constructor(client_id: string, client_secret: string, redirect_uri: string) {
super({
Expand Down Expand Up @@ -303,6 +322,7 @@ export class CalendarAuth {

return new calendar_v3.Calendar({
auth: googleAuthClient,
retryConfig: GOOGLE_CALENDAR_RETRY_CONFIG,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,11 @@ describe("GoogleCalendarService credential handling", () => {

expectOAuth2InstanceToBeCreated();

expect(calendarMock.calendar_v3.Calendar).toHaveBeenCalledWith({
auth: getLastCreatedOAuth2Client(),
});
expect(calendarMock.calendar_v3.Calendar).toHaveBeenCalledWith(
expect.objectContaining({
auth: getLastCreatedOAuth2Client(),
})
);
await expectCredentialsInDb([
expect.objectContaining({
id: regularCredential.id,
Expand Down Expand Up @@ -310,3 +312,22 @@ describe("GoogleCalendarService credential handling", () => {
});
});
});

describe("GoogleCalendarService retry configuration", () => {
test("retries PATCH requests and 403 rate-limit errors, but not POST", async () => {
const regularCredential = await createCredentialForCalendarService();
mockSuccessfulCalendarListFetch();
const calendarService = BuildCalendarService(regularCredential);
await calendarService.listCalendars();

const lastCall = calendarMock.calendar_v3.Calendar.mock.calls.at(-1);
const retryConfig = lastCall?.[0]?.retryConfig;
expect(retryConfig).toBeDefined();

// PATCH (idempotent) and 403 rate-limit errors are retried; POST/insert is not (avoids duplicate events)
expect(retryConfig?.httpMethodsToRetry).toContain("PATCH");
expect(retryConfig?.httpMethodsToRetry).not.toContain("POST");
expect(retryConfig?.statusCodesToRetry).toContainEqual([403, 403]);
expect(retryConfig?.statusCodesToRetry).toContainEqual([408, 408]);
});
});
Loading