Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code 03db2b1837 fix: handle network errors as temporary in Microsoft calendar import
https://sonarly.com/issue/19081?type=bug

Transient network failures (TCP-level "fetch failed") when calling Microsoft Graph API during calendar event import are misclassified as UNKNOWN errors, causing the calendar channel to be permanently marked as failed instead of being retried.

Fix: The root cause is that `parseMicrosoftCalendarError` only switches on HTTP `statusCode`. When Node.js undici throws `TypeError: fetch failed` (a TCP-level network failure), there is no `statusCode` — the error falls to `default` and is classified as UNKNOWN. This triggers `handleUnknownException`, which permanently marks the calendar channel as FAILED_UNKNOWN and flushes all pending events — an unrecoverable state for a transient network blip.

Changes made:

1. **parse-microsoft-calendar-error.util.ts** (primary fix):
   - Added `isNetworkError()` helper that detects TypeError instances (undici fetch failures) and errors with `cause.code` matching known network error codes (ECONNRESET, ENOTFOUND, ETIMEDOUT, ECONNABORTED, ECONNREFUSED, EHOSTUNREACH, ERR_NETWORK, UND_ERR_CONNECT_TIMEOUT, UND_ERR_SOCKET).
   - Network errors are now classified as TEMPORARY_ERROR → retry with backoff instead of permanent failure.
   - Added safety-net guard: errors with no `statusCode` that aren't detected as network errors also get TEMPORARY_ERROR (fail-safe toward retry).
   - Reclassified HTTP 500/502/503/504 from UNKNOWN to TEMPORARY_ERROR, matching the messaging-side pattern.

2. **parse-microsoft-messages-import.util.ts** (proactive fix):
   - Added the same `isNetworkError()` guard before HTTP status code checks, preventing the identical bug from manifesting in Microsoft message import.
2026-03-27 17:07:26 +00:00
8 changed files with 187 additions and 315 deletions
@@ -1,205 +0,0 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type View } from '@/views/types/View';
import {
NavigationMenuItemType,
ViewKey,
type NavigationMenuItem,
} from '~/generated-metadata/graphql';
import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel';
type ObjectMetadata = Pick<
EnrichedObjectMetadataItem,
'id' | 'labelPlural' | 'nameSingular'
>;
type ViewMetadata = Pick<View, 'id' | 'name' | 'objectMetadataId' | 'key'>;
const objectMetadataItems: ObjectMetadata[] = [
{ id: 'obj-1', labelPlural: 'Notes', nameSingular: 'note' },
{ id: 'obj-2', labelPlural: 'Companies', nameSingular: 'company' },
];
const views: ViewMetadata[] = [
{
id: 'view-index',
name: 'All Notes',
objectMetadataId: 'obj-1',
key: ViewKey.INDEX,
},
{
id: 'view-custom',
name: 'My Custom View',
objectMetadataId: 'obj-1',
key: null,
},
];
const baseItem: NavigationMenuItem = {
id: 'nav-1',
type: NavigationMenuItemType.OBJECT,
position: 0,
createdAt: '',
updatedAt: '',
};
describe('getNavigationMenuItemLabel', () => {
describe('when type is OBJECT', () => {
it('should return labelPlural for a matching object', () => {
const item = {
...baseItem,
type: NavigationMenuItemType.OBJECT,
targetObjectMetadataId: 'obj-1',
};
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
'Notes',
);
});
it('should return empty string when object is not found', () => {
const item = {
...baseItem,
type: NavigationMenuItemType.OBJECT,
targetObjectMetadataId: 'nonexistent',
};
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
'',
);
});
});
describe('when type is VIEW', () => {
it('should return the resolved view name for an INDEX view', () => {
const item = {
...baseItem,
type: NavigationMenuItemType.VIEW,
viewId: 'view-index',
};
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
'All Notes',
);
});
it('should return the view name for a non-INDEX view', () => {
const item = {
...baseItem,
type: NavigationMenuItemType.VIEW,
viewId: 'view-custom',
};
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
'My Custom View',
);
});
it('should return empty string when view is not found', () => {
const item = {
...baseItem,
type: NavigationMenuItemType.VIEW,
viewId: 'nonexistent',
};
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
'',
);
});
});
describe('when type is LINK', () => {
it('should return the item name when present', () => {
const item = {
...baseItem,
type: NavigationMenuItemType.LINK,
name: 'Documentation',
link: 'https://docs.example.com',
};
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
'Documentation',
);
});
it('should return the link URL when name is null', () => {
const item = {
...baseItem,
type: NavigationMenuItemType.LINK,
name: null,
link: 'https://docs.example.com',
};
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
'https://docs.example.com',
);
});
it('should return "Link" when both name and link are empty', () => {
const item = {
...baseItem,
type: NavigationMenuItemType.LINK,
name: null,
link: ' ',
};
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
'Link',
);
});
});
describe('when type is RECORD', () => {
it('should return labelIdentifier from targetRecordIdentifier', () => {
const item = {
...baseItem,
type: NavigationMenuItemType.RECORD,
targetRecordIdentifier: {
id: 'rec-1',
labelIdentifier: 'Acme Corp',
},
};
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
'Acme Corp',
);
});
it('should return empty string when targetRecordIdentifier is missing', () => {
const item = {
...baseItem,
type: NavigationMenuItemType.RECORD,
targetRecordIdentifier: null,
};
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
'',
);
});
});
describe('when type is FOLDER', () => {
it('should return the item name when present', () => {
const item = {
...baseItem,
type: NavigationMenuItemType.FOLDER,
name: 'Sales',
};
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
'Sales',
);
});
it('should return "Folder" when name is null', () => {
const item = {
...baseItem,
type: NavigationMenuItemType.FOLDER,
name: null,
};
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
'Folder',
);
});
});
});
@@ -1,10 +1,9 @@
import { getLinkNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/link/utils/getLinkNavigationMenuItemComputedLink';
import { getObjectNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/object/utils/getObjectNavigationMenuItemComputedLink';
import { getRecordNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/record/utils/getRecordNavigationMenuItemComputedLink';
import { getViewNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/view/utils/getViewNavigationMenuItemComputedLink';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { recordIdentifierToObjectRecordIdentifier } from '@/navigation-menu-item/common/utils/recordIdentifierToObjectRecordIdentifier';
import { type View } from '@/views/types/View';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { ViewKey } from '@/views/types/ViewKey';
import { AppPath, NavigationMenuItemType } from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const getNavigationMenuItemComputedLink = (
@@ -13,22 +12,64 @@ export const getNavigationMenuItemComputedLink = (
views: Pick<View, 'id' | 'objectMetadataId' | 'key'>[],
): string => {
switch (item.type) {
case NavigationMenuItemType.OBJECT:
return getObjectNavigationMenuItemComputedLink(
item,
objectMetadataItems,
views,
case NavigationMenuItemType.OBJECT: {
const objectMetadataItem = objectMetadataItems.find(
(meta) => meta.id === item.targetObjectMetadataId,
);
case NavigationMenuItemType.VIEW:
return getViewNavigationMenuItemComputedLink(
item,
objectMetadataItems,
views,
if (!isDefined(objectMetadataItem)) {
return '';
}
const indexView = views.find(
(view) =>
view.objectMetadataId === objectMetadataItem.id &&
view.key === ViewKey.INDEX,
);
case NavigationMenuItemType.LINK:
return getLinkNavigationMenuItemComputedLink(item);
case NavigationMenuItemType.RECORD:
return getRecordNavigationMenuItemComputedLink(item, objectMetadataItems);
return getAppPath(
AppPath.RecordIndexPage,
{ objectNamePlural: objectMetadataItem.namePlural },
indexView ? { viewId: indexView.id } : {},
);
}
case NavigationMenuItemType.VIEW: {
const view = views.find((view) => view.id === item.viewId);
if (!isDefined(view)) {
return '';
}
const objectMetadataItem = objectMetadataItems.find(
(meta) => meta.id === view.objectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
return '';
}
return getAppPath(
AppPath.RecordIndexPage,
{ objectNamePlural: objectMetadataItem.namePlural },
{ viewId: item.viewId! },
);
}
case NavigationMenuItemType.LINK: {
const linkUrl = (item.link ?? '').trim();
if (linkUrl.startsWith('http://') || linkUrl.startsWith('https://')) {
return linkUrl;
}
return linkUrl ? `https://${linkUrl}` : '';
}
case NavigationMenuItemType.RECORD: {
const objectMetadataItem = objectMetadataItems.find(
(meta) => meta.id === item.targetObjectMetadataId,
);
if (
!isDefined(objectMetadataItem) ||
!isDefined(item.targetRecordIdentifier)
) {
return '';
}
const objectRecordIdentifier = recordIdentifierToObjectRecordIdentifier({
recordIdentifier: item.targetRecordIdentifier,
objectMetadataItem,
});
return objectRecordIdentifier.linkToShowPage ?? '';
}
default:
return '';
}
@@ -1,11 +1,8 @@
import { getFolderNavigationMenuItemLabel } from '@/navigation-menu-item/display/folder/utils/getFolderNavigationMenuItemLabel';
import { getLinkNavigationMenuItemLabel } from '@/navigation-menu-item/display/link/utils/getLinkNavigationMenuItemLabel';
import { getObjectNavigationMenuItemLabel } from '@/navigation-menu-item/display/object/utils/getObjectNavigationMenuItemLabel';
import { getRecordNavigationMenuItemLabel } from '@/navigation-menu-item/display/record/utils/getRecordNavigationMenuItemLabel';
import { getViewNavigationMenuItemLabel } from '@/navigation-menu-item/display/view/utils/getViewNavigationMenuItemLabel';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type View } from '@/views/types/View';
import { ViewKey } from '@/views/types/ViewKey';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const getNavigationMenuItemLabel = (
@@ -17,16 +14,35 @@ export const getNavigationMenuItemLabel = (
views: Pick<View, 'id' | 'name' | 'objectMetadataId' | 'key'>[],
): string => {
switch (item.type) {
case NavigationMenuItemType.OBJECT:
return getObjectNavigationMenuItemLabel(item, objectMetadataItems);
case NavigationMenuItemType.VIEW:
return getViewNavigationMenuItemLabel(item, views);
case NavigationMenuItemType.LINK:
return getLinkNavigationMenuItemLabel(item);
case NavigationMenuItemType.RECORD:
return getRecordNavigationMenuItemLabel(item);
case NavigationMenuItemType.FOLDER:
return getFolderNavigationMenuItemLabel(item);
case NavigationMenuItemType.OBJECT: {
const objectMetadataItem = objectMetadataItems.find(
(meta) => meta.id === item.targetObjectMetadataId,
);
return objectMetadataItem?.labelPlural ?? '';
}
case NavigationMenuItemType.VIEW: {
const view = views.find((view) => view.id === item.viewId);
if (!isDefined(view)) {
return '';
}
if (view.key === ViewKey.INDEX) {
const objectMetadataItem = objectMetadataItems.find(
(meta) => meta.id === view.objectMetadataId,
);
return objectMetadataItem?.labelPlural ?? view.name;
}
return view.name;
}
case NavigationMenuItemType.LINK: {
const linkUrl = (item.link ?? '').trim();
return (item.name ?? linkUrl) || 'Link';
}
case NavigationMenuItemType.RECORD: {
return item.targetRecordIdentifier?.labelIdentifier ?? '';
}
case NavigationMenuItemType.FOLDER: {
return item.name ?? 'Folder';
}
default:
return item.name ?? '';
}
@@ -1,14 +1,23 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type View } from '@/views/types/View';
import { ViewKey } from '@/views/types/ViewKey';
import { isDefined } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const getViewNavigationMenuItemLabel = (
item: Pick<NavigationMenuItem, 'viewId'>,
views: Pick<View, 'id' | 'name' | 'objectMetadataId' | 'key'>[],
objectMetadataItems: Pick<EnrichedObjectMetadataItem, 'id' | 'labelPlural'>[],
): string => {
const view = views.find((view) => view.id === item.viewId);
if (!isDefined(view)) {
return '';
}
if (view.key === ViewKey.INDEX) {
const objectMetadataItem = objectMetadataItems.find(
(meta) => meta.id === view.objectMetadataId,
);
return objectMetadataItem?.labelPlural ?? view.name;
}
return view.name;
};
@@ -42,74 +42,6 @@ describe('SubdomainManagerService', () => {
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
});
describe('getSubdomainAndDomainFromUrl', () => {
it('should extract subdomain from a full URL with protocol', () => {
jest
.spyOn(twentyConfigService, 'get')
.mockImplementation((key: string) => {
const env = {
FRONTEND_URL: 'https://twenty.com',
DEFAULT_SUBDOMAIN: 'app',
};
// @ts-expect-error legacy noImplicitAny
return env[key];
});
const result =
domainServerConfigService.getSubdomainAndDomainFromUrl(
'https://myworkspace.twenty.com',
);
expect(result.subdomain).toBe('myworkspace');
expect(result.domain).toBeNull();
});
it('should handle URL without protocol scheme', () => {
jest
.spyOn(twentyConfigService, 'get')
.mockImplementation((key: string) => {
const env = {
FRONTEND_URL: 'https://twenty.com',
DEFAULT_SUBDOMAIN: 'app',
};
// @ts-expect-error legacy noImplicitAny
return env[key];
});
const result =
domainServerConfigService.getSubdomainAndDomainFromUrl(
'myworkspace.twenty.com',
);
expect(result.subdomain).toBe('myworkspace');
expect(result.domain).toBeNull();
});
it('should return custom domain for non-front domain URLs', () => {
jest
.spyOn(twentyConfigService, 'get')
.mockImplementation((key: string) => {
const env = {
FRONTEND_URL: 'https://twenty.com',
DEFAULT_SUBDOMAIN: 'app',
};
// @ts-expect-error legacy noImplicitAny
return env[key];
});
const result =
domainServerConfigService.getSubdomainAndDomainFromUrl(
'custom.example.com',
);
expect(result.subdomain).toBeUndefined();
expect(result.domain).toBe('custom.example.com');
});
});
describe('buildBaseUrl', () => {
it('should build the base URL from environment variables', () => {
jest
@@ -46,9 +46,7 @@ export class DomainServerConfigService {
}
getSubdomainAndDomainFromUrl = (url: string) => {
const urlWithProtocol = url.includes('://') ? url : `https://${url}`;
const { hostname: originHostname } = new URL(urlWithProtocol);
const { hostname: originHostname } = new URL(url);
const frontDomain = this.getFrontUrl().hostname;
@@ -4,12 +4,53 @@ import {
CalendarEventImportDriverException,
CalendarEventImportDriverExceptionCode,
} from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
import { isDefined } from 'twenty-shared/utils';
const isNetworkError = (error: GraphError): boolean => {
if (error instanceof TypeError) {
return true;
}
const cause = (error as unknown as { cause?: { code?: string } })?.cause;
if (!isDefined(cause)) {
return false;
}
const networkErrorCodes = [
'ECONNRESET',
'ENOTFOUND',
'ECONNABORTED',
'ETIMEDOUT',
'ECONNREFUSED',
'EHOSTUNREACH',
'ERR_NETWORK',
'UND_ERR_CONNECT_TIMEOUT',
'UND_ERR_SOCKET',
];
return isDefined(cause.code) && networkErrorCodes.includes(cause.code);
};
export const parseMicrosoftCalendarError = (
error: GraphError,
): CalendarEventImportDriverException => {
if (isNetworkError(error)) {
return new CalendarEventImportDriverException(
`Microsoft Calendar network error: ${error.message}`,
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
);
}
const { statusCode, message } = error;
if (!isDefined(statusCode)) {
return new CalendarEventImportDriverException(
`Microsoft Calendar error without status code: ${message}`,
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
);
}
switch (statusCode) {
case 400:
return new CalendarEventImportDriverException(
@@ -40,6 +81,10 @@ export const parseMicrosoftCalendarError = (
);
case 429:
case 500:
case 502:
case 503:
case 504:
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
@@ -56,11 +101,6 @@ export const parseMicrosoftCalendarError = (
message,
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
case 500:
return new CalendarEventImportDriverException(
message,
CalendarEventImportDriverExceptionCode.UNKNOWN,
);
default:
return new CalendarEventImportDriverException(
@@ -4,6 +4,39 @@ import {
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { isDefined } from 'twenty-shared/utils';
const isNetworkError = (error: {
statusCode: number;
message?: string;
}): boolean => {
if (error instanceof TypeError) {
return true;
}
if (isDefined(error.statusCode)) {
return false;
}
const cause = (error as unknown as { cause?: { code?: string } })?.cause;
if (!isDefined(cause) || !isDefined(cause.code)) {
return false;
}
const networkErrorCodes = [
'ECONNRESET',
'ENOTFOUND',
'ECONNABORTED',
'ETIMEDOUT',
'ECONNREFUSED',
'EHOSTUNREACH',
'ERR_NETWORK',
'UND_ERR_CONNECT_TIMEOUT',
'UND_ERR_SOCKET',
];
return networkErrorCodes.includes(cause.code);
};
export const parseMicrosoftMessagesImportError = (
error: {
statusCode: number;
@@ -12,6 +45,14 @@ export const parseMicrosoftMessagesImportError = (
},
options?: { cause?: Error },
): MessageImportDriverException => {
if (isNetworkError(error)) {
return new MessageImportDriverException(
`Microsoft Graph API network error: ${error.message}`,
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
{ cause: options?.cause },
);
}
if (error.statusCode === 400) {
if (!isDefined(error.message)) {
return new MessageImportDriverException(