fix(server): stop treating Microsoft Graph 401 as a permanent failure (#22989)

In production we are seeing some accounts being occasionally marked as
permanent failure even though when you check with their refresh token it
never actually failed this PR stops treating 401 as permanent failure
and treats them as Transient error.

We already have token refresh stage that runs before the actual import
stage, so if it's an actual revoke token error, it should catch it.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22989?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
neo773
2026-07-17 19:13:50 +05:30
committed by GitHub
parent 39082cf787
commit f6612e5a85
4 changed files with 214 additions and 20 deletions
@@ -0,0 +1,88 @@
import { type GraphError } from '@microsoft/microsoft-graph-client';
import { CalendarEventImportDriverExceptionCode } from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
import { parseMicrosoftCalendarError } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/utils/parse-microsoft-calendar-error.util';
const buildGraphError = ({
statusCode,
code = null,
message = 'error message',
}: {
statusCode: number;
code?: string | null;
message?: string;
}) => ({ statusCode, code, message }) as GraphError;
describe('parseMicrosoftCalendarError', () => {
it('should be temporary when the access token is expired so the next attempt can refresh it', () => {
const exception = parseMicrosoftCalendarError(
buildGraphError({
statusCode: 401,
code: 'InvalidAuthenticationToken',
message: 'Lifetime validation failed, the token is expired.',
}),
);
expect(exception.code).toBe(
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
);
});
it('should be insufficient permissions when access is denied', () => {
const exception = parseMicrosoftCalendarError(
buildGraphError({ statusCode: 403, code: 'ErrorAccessDenied' }),
);
expect(exception.code).toBe(
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
});
it('should be not found for a 404 that is not a mailbox error', () => {
const exception = parseMicrosoftCalendarError(
buildGraphError({ statusCode: 404, code: 'ResourceNotFound' }),
);
expect(exception.code).toBe(
CalendarEventImportDriverExceptionCode.NOT_FOUND,
);
});
it('should be a sync cursor error when the delta token is no longer valid', () => {
const exception = parseMicrosoftCalendarError(
buildGraphError({ statusCode: 410, code: 'SyncStateNotFound' }),
);
expect(exception.code).toBe(
CalendarEventImportDriverExceptionCode.SYNC_CURSOR_ERROR,
);
});
it('should be temporary when throttled', () => {
const exception = parseMicrosoftCalendarError(
buildGraphError({ statusCode: 429, code: 'TooManyRequests' }),
);
expect(exception.code).toBe(
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
);
});
it('should be temporary when the service is unavailable', () => {
const exception = parseMicrosoftCalendarError(
buildGraphError({ statusCode: 503 }),
);
expect(exception.code).toBe(
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
);
});
it('should be unknown for an unhandled status code', () => {
const exception = parseMicrosoftCalendarError(
buildGraphError({ statusCode: 418 }),
);
expect(exception.code).toBe(CalendarEventImportDriverExceptionCode.UNKNOWN);
});
});
@@ -26,17 +26,6 @@ export const parseMicrosoftCalendarError = (
);
case 404:
if (
message?.includes(
'The mailbox is either inactive, soft-deleted, or is hosted on-premise.',
)
) {
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
}
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.NOT_FOUND,
@@ -68,7 +57,7 @@ export const parseMicrosoftCalendarError = (
case 401:
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
);
default:
return new CalendarEventImportDriverException(
@@ -0,0 +1,118 @@
import { MessageImportDriverExceptionCode } from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { parseMicrosoftMessagesImportError } from 'src/modules/messaging/message-import-manager/drivers/microsoft/utils/parse-microsoft-messages-import.util';
describe('parseMicrosoftMessagesImportError', () => {
it('should be temporary when the access token is expired so the next attempt can refresh it', () => {
const exception = parseMicrosoftMessagesImportError({
statusCode: 401,
code: 'InvalidAuthenticationToken',
message: 'Lifetime validation failed, the token is expired.',
});
expect(exception.code).toBe(
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
});
it('should keep the Microsoft error code and message so the failure stays diagnosable', () => {
const exception = parseMicrosoftMessagesImportError({
statusCode: 401,
code: 'InvalidAuthenticationToken',
message: 'Lifetime validation failed, the token is expired.',
});
expect(exception.message).toContain('InvalidAuthenticationToken');
expect(exception.message).toContain(
'Lifetime validation failed, the token is expired.',
);
});
it('should be insufficient permissions when access is denied', () => {
const exception = parseMicrosoftMessagesImportError({
statusCode: 403,
code: 'ErrorAccessDenied',
message: 'Access is denied.',
});
expect(exception.code).toBe(
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
expect(exception.message).toContain('ErrorAccessDenied');
});
it('should be insufficient permissions when the mailbox is not enabled for the REST API', () => {
const exception = parseMicrosoftMessagesImportError({
statusCode: 404,
code: 'MailboxNotEnabledForRESTAPI',
message:
'The mailbox is either inactive, soft-deleted, or is hosted on-premise.',
});
expect(exception.code).toBe(
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
});
it('should classify the mailbox error on its code rather than its message wording', () => {
const exception = parseMicrosoftMessagesImportError({
statusCode: 404,
code: 'MailboxNotEnabledForRESTAPI',
message: 'Some reworded message from Microsoft.',
});
expect(exception.code).toBe(
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
});
it('should be not found for a 404 that is not a mailbox error', () => {
const exception = parseMicrosoftMessagesImportError({
statusCode: 404,
code: 'ResourceNotFound',
message: 'Resource not found.',
});
expect(exception.code).toBe(MessageImportDriverExceptionCode.NOT_FOUND);
});
it('should be a sync cursor error when the delta token is no longer valid', () => {
const exception = parseMicrosoftMessagesImportError({
statusCode: 410,
code: 'SyncStateNotFound',
message: 'The sync state is not found.',
});
expect(exception.code).toBe(
MessageImportDriverExceptionCode.SYNC_CURSOR_ERROR,
);
});
it('should be temporary when throttled', () => {
const exception = parseMicrosoftMessagesImportError({
statusCode: 429,
code: 'TooManyRequests',
message: 'Please retry again later.',
});
expect(exception.code).toBe(
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
});
it('should be temporary when a 400 has an empty error body', () => {
const exception = parseMicrosoftMessagesImportError({ statusCode: 400 });
expect(exception.code).toBe(
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
});
it('should be unknown for an unhandled status code', () => {
const exception = parseMicrosoftMessagesImportError({
statusCode: 418,
message: 'I am a teapot.',
});
expect(exception.code).toBe(MessageImportDriverExceptionCode.UNKNOWN);
});
});
@@ -4,6 +4,9 @@ import {
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { isDefined } from 'twenty-shared/utils';
const MICROSOFT_MAILBOX_NOT_ENABLED_FOR_REST_API_ERROR_CODE =
'MailboxNotEnabledForRESTAPI';
export const parseMicrosoftMessagesImportError = (
error: {
statusCode: number;
@@ -30,26 +33,22 @@ export const parseMicrosoftMessagesImportError = (
if (error.statusCode === 401) {
return new MessageImportDriverException(
'Unauthorized access to Microsoft Graph API',
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
`Unauthorized access to Microsoft Graph API - code:${error.code} ${error.message}`,
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
{ cause: options?.cause },
);
}
if (error.statusCode === 403) {
return new MessageImportDriverException(
'Forbidden access to Microsoft Graph API',
`Forbidden access to Microsoft Graph API - code:${error.code} ${error.message}`,
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
{ cause: options?.cause },
);
}
if (error.statusCode === 404) {
if (
error.message?.includes(
'The mailbox is either inactive, soft-deleted, or is hosted on-premise.',
)
) {
if (error.code === MICROSOFT_MAILBOX_NOT_ENABLED_FOR_REST_API_ERROR_CODE) {
return new MessageImportDriverException(
`Disabled, deleted, inactive or no licence Microsoft account - code:${error.code}`,
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,