Migrate gmail fetch by batch to library (#17514)

Replace manual HTTP batch implementation with
`@jrmdayn/googleapis-batcher` library (we already use this for fetching
message list)

Initial real testing works fine but do not merge yet needs more
extensive real test runs
This commit is contained in:
neo773
2026-02-04 04:51:56 +05:30
committed by GitHub
parent 867b03393b
commit 4ae5732483
12 changed files with 125 additions and 695 deletions
@@ -1,4 +1,3 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -11,7 +10,6 @@ import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects
import { EmailAliasManagerModule } from 'src/modules/connected-account/email-alias-manager/email-alias-manager.module';
import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module';
import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module';
import { GmailFetchByBatchService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-fetch-by-batch.service';
import { GmailGetHistoryService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-history.service';
import { GmailGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service';
import { GmailGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-messages.service';
@@ -21,9 +19,6 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p
@Module({
imports: [
HttpModule.register({
baseURL: 'https://www.googleapis.com/batch/gmail/v1',
}),
TwentyConfigModule,
ObjectMetadataRepositoryModule.forFeature([BlocklistWorkspaceEntity]),
MessagingCommonModule,
@@ -36,7 +31,6 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p
],
providers: [
GmailGetHistoryService,
GmailFetchByBatchService,
GmailGetMessagesService,
GmailGetMessageListService,
GmailMessageListFetchErrorHandler,
@@ -1,217 +0,0 @@
import { type GmailApiBatchError } from 'src/modules/messaging/message-import-manager/drivers/gmail/types/gmail-api-batch-error.type';
const gmailBatchApiErrorMocks = {
// 400 Bad Request - Invalid query parameters
badRequest: {
code: 400,
errors: [
{
domain: 'global',
location: 'orderBy',
locationType: 'parameter',
message:
'Sorting is not supported for queries with fullText terms. Results are always in descending relevance order.',
reason: 'badRequest',
},
],
message:
'Sorting is not supported for queries with fullText terms. Results are always in descending relevance order.',
},
// 400 Invalid Grant
invalidGrant: {
code: 400,
errors: [
{
domain: 'global',
reason: 'invalid_grant',
message: 'Invalid Credentials',
},
],
message: 'Invalid Credentials',
},
// 400 Failed Precondition
failedPrecondition: {
code: 400,
errors: [
{
domain: 'global',
reason: 'failedPrecondition',
message: 'Failed Precondition',
},
],
message: 'Failed Precondition',
},
// 401 Invalid Credentials
invalidCredentials: {
errors: [
{
domain: 'global',
reason: 'authError',
message: 'Invalid Credentials',
locationType: 'header',
location: 'Authorization',
},
],
code: 401,
message: 'Invalid Credentials',
},
// 404 Not Found
notFound: {
errors: [
{
domain: 'global',
reason: 'notFound',
message: 'Resource not found: userId',
location: 'userId',
locationType: 'parameter',
},
],
code: 404,
message: 'Resource not found: userId',
},
// 410 Gone
gone: {
errors: [
{
domain: 'global',
reason: 'resourceGone',
message: 'Resource has been deleted',
location: 'messageId',
locationType: 'parameter',
},
],
code: 410,
message: 'Resource has been deleted',
},
// 403 Daily Limit Exceeded
dailyLimitExceeded: {
errors: [
{
domain: 'usageLimits',
reason: 'dailyLimitExceeded',
message: 'Daily Limit Exceeded',
},
],
code: 403,
message: 'Daily Limit Exceeded',
},
// 403 User Rate Limit Exceeded
userRateLimitExceeded: {
errors: [
{
domain: 'usageLimits',
reason: 'userRateLimitExceeded',
message: 'User Rate Limit Exceeded',
},
],
code: 403,
message: 'User Rate Limit Exceeded',
},
// 403 Rate Limit Exceeded
rateLimitExceeded: {
errors: [
{
domain: 'usageLimits',
reason: 'rateLimitExceeded',
message: 'Rate Limit Exceeded',
},
],
code: 403,
message: 'Rate Limit Exceeded',
},
// 403 Domain Policy Error
domainPolicyError: {
errors: [
{
domain: 'global',
reason: 'domainPolicy',
message: 'The domain administrators have disabled Gmail apps.',
},
],
code: 403,
message: 'The domain administrators have disabled Gmail apps.',
},
// 429 Too Many Requests (Concurrent Requests)
tooManyConcurrentRequests: {
errors: [
{
domain: 'global',
reason: 'rateLimitExceeded',
message: 'Too many concurrent requests for user',
},
],
code: 429,
message: 'Too many concurrent requests for user',
},
// 500 Backend Error
backendError: {
errors: [
{
domain: 'global',
reason: 'backendError',
message: 'Backend Error',
},
],
code: 500,
message: 'Backend Error',
},
getError: function (code: number, type?: string): GmailApiBatchError {
switch (code) {
case 400:
switch (type) {
case 'invalid_grant':
return this.invalidGrant;
case 'failedPrecondition':
return this.failedPrecondition;
default:
return this.badRequest;
}
case 401:
return this.invalidCredentials;
case 403:
switch (type) {
case 'dailyLimit':
return this.dailyLimitExceeded;
case 'userRateLimit':
return this.userRateLimitExceeded;
case 'rateLimit':
return this.rateLimitExceeded;
case 'domainPolicy':
return this.domainPolicyError;
default:
return this.rateLimitExceeded;
}
case 404:
return this.notFound;
case 410:
return this.gone;
case 429:
switch (type) {
case 'concurrent':
return this.tooManyConcurrentRequests;
case 'mailSending':
return this.mailSendingLimitExceeded;
default:
return this.tooManyConcurrentRequests;
}
case 500:
return this.backendError;
default:
throw new Error(`Unknown error code: ${code}`);
}
},
};
export default gmailBatchApiErrorMocks;
@@ -1,147 +0,0 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { type AxiosResponse } from 'axios';
import { type GmailMessageParsedResponse } from 'src/modules/messaging/message-import-manager/drivers/gmail/types/gmail-message-parsed-response.type';
import { createQueriesFromMessageIds } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/create-queries-from-message-ids.util';
import { type BatchQueries } from 'src/modules/messaging/message-import-manager/types/batch-queries';
@Injectable()
export class GmailFetchByBatchService {
constructor(private readonly httpService: HttpService) {}
async fetchAllByBatches(
messageIds: string[],
accessToken: string,
boundary: string,
): Promise<{
messageIdsByBatch: string[][];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
batchResponses: AxiosResponse<any, any>[];
}> {
const batchLimit = 20;
let batchOffset = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let batchResponses: AxiosResponse<any, any>[] = [];
const messageIdsByBatch: string[][] = [];
while (batchOffset < messageIds.length) {
const batchResponse = await this.fetchBatch(
messageIds,
accessToken,
batchOffset,
batchLimit,
boundary,
);
batchResponses = batchResponses.concat(batchResponse);
messageIdsByBatch.push(
messageIds.slice(batchOffset, batchOffset + batchLimit),
);
batchOffset += batchLimit;
}
return { messageIdsByBatch, batchResponses };
}
async fetchBatch(
messageIds: string[],
accessToken: string,
batchOffset: number,
batchLimit: number,
boundary: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<AxiosResponse<any, any>> {
const queries = createQueriesFromMessageIds(messageIds);
const limitedQueries = queries.slice(batchOffset, batchOffset + batchLimit);
const response = await this.httpService.axiosRef.post(
'/',
this.createBatchBody(limitedQueries, boundary),
{
headers: {
'Content-Type': 'multipart/mixed; boundary=' + boundary,
Authorization: 'Bearer ' + accessToken,
},
},
);
return response;
}
createBatchBody(queries: BatchQueries, boundary: string): string {
let batchBody: string[] = [];
queries.forEach(function (call) {
const method = 'GET';
const uri = call.uri;
batchBody = batchBody.concat([
'--',
boundary,
'\r\n',
'Content-Type: application/http',
'\r\n\r\n',
method,
' ',
uri,
'\r\n\r\n',
]);
});
return batchBody.concat(['--', boundary, '--']).join('');
}
parseBatch(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
responseCollection: AxiosResponse<any, any>,
): GmailMessageParsedResponse[] {
const responseItems: GmailMessageParsedResponse[] = [];
const boundary = this.getBatchSeparator(responseCollection);
const responseLines: string[] = responseCollection.data.split(
'--' + boundary,
);
responseLines.forEach(function (response) {
const startJson = response.indexOf('{');
const endJson = response.lastIndexOf('}');
if (startJson < 0 || endJson < 0) return;
const responseJson = response.substring(startJson, endJson + 1);
const item = JSON.parse(responseJson);
responseItems.push(item);
});
return responseItems;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getBatchSeparator(responseCollection: AxiosResponse<any, any>): string {
const headers = responseCollection.headers;
const contentType: string = headers['content-type'];
if (!contentType) return '';
const components = contentType.split('; ');
const boundary = components.find((item) => item.startsWith('boundary='));
return boundary?.replace('boundary=', '').trim() || '';
}
}
@@ -1,23 +1,21 @@
import { Injectable } from '@nestjs/common';
import { type AxiosResponse } from 'axios';
import { type gmail_v1 as gmailV1 } from 'googleapis';
import { batchFetchImplementation } from '@jrmdayn/googleapis-batcher';
import { type gmail_v1 as gmailV1, google } from 'googleapis';
import { isDefined } from 'twenty-shared/utils';
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import {
MessageImportDriverException,
MessageImportDriverExceptionCode,
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { GmailFetchByBatchService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-fetch-by-batch.service';
import { GmailMessagesImportErrorHandler } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-messages-import-error-handler.service';
import { parseAndFormatGmailMessage } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-and-format-gmail-message.util';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
const GMAIL_BATCH_REQUEST_MAX_SIZE = 50;
@Injectable()
export class GmailGetMessagesService {
constructor(
private readonly fetchByBatchesService: GmailFetchByBatchService,
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
private readonly gmailMessagesImportErrorHandler: GmailMessagesImportErrorHandler,
) {}
@@ -25,62 +23,56 @@ export class GmailGetMessagesService {
messageIds: string[],
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'accessToken' | 'id' | 'handle' | 'handleAliases'
| 'provider'
| 'accessToken'
| 'refreshToken'
| 'id'
| 'handle'
| 'handleAliases'
>,
): Promise<MessageWithParticipants[]> {
if (!isDefined(connectedAccount.accessToken)) {
throw new MessageImportDriverException(
'Access token is required',
MessageImportDriverExceptionCode.ACCESS_TOKEN_MISSING,
);
}
const { messageIdsByBatch, batchResponses } =
await this.fetchByBatchesService.fetchAllByBatches(
messageIds,
connectedAccount.accessToken,
'batch_gmail_messages',
);
const messages = batchResponses.flatMap((response, index) => {
return this.formatBatchResponseAsMessage(
messageIdsByBatch[index],
response,
const oAuth2Client =
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
connectedAccount,
);
const batchedFetchImplementation = batchFetchImplementation({
maxBatchSize: GMAIL_BATCH_REQUEST_MAX_SIZE,
});
const batchedGmailClient = google.gmail({
version: 'v1',
auth: oAuth2Client,
fetchImplementation: batchedFetchImplementation,
});
const messagePromises = messageIds.map((messageId) =>
batchedGmailClient.users.messages
.get({
userId: 'me',
id: messageId,
})
.then((response) => ({ messageId, data: response.data, error: null }))
.catch((error) => ({ messageId, data: null, error })),
);
const results = await Promise.all(messagePromises);
const messages = results
.map(({ messageId, data, error }) => {
if (error) {
this.gmailMessagesImportErrorHandler.handleError(error, messageId);
return undefined;
}
return parseAndFormatGmailMessage(
data as gmailV1.Schema$Message,
connectedAccount,
);
})
.filter(isDefined);
return messages;
}
private formatBatchResponseAsMessage(
messageIds: string[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
responseCollection: AxiosResponse<any, any>,
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'handle' | 'handleAliases'
>,
): MessageWithParticipants[] {
const parsedResponses =
this.fetchByBatchesService.parseBatch(responseCollection);
const messages = parsedResponses.map((response, index) => {
if ('error' in response) {
this.gmailMessagesImportErrorHandler.handleError(
response.error,
messageIds[index],
);
return undefined;
}
return parseAndFormatGmailMessage(
response as gmailV1.Schema$Message,
connectedAccount,
);
});
return messages.filter(isDefined);
}
}
@@ -1,14 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import {
MessageImportDriverException,
MessageImportDriverExceptionCode,
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { isGmailApiBatchError } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/is-gmail-api-batch-error.util';
import { isGmailApiError } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/is-gmail-api-error.util';
import { isGmailNetworkError } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/is-gmail-network-error.util';
import { parseGmailApiBatchError } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-gmail-api-batch-error.util';
import { parseGmailApiError } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-gmail-api-error.util';
import { parseGmailNetworkError } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-gmail-network-error.util';
@Injectable()
@@ -19,21 +17,22 @@ export class GmailMessagesImportErrorHandler {
public handleError(error: unknown, messageExternalId: string): void {
this.logger.error(
`Gmail: Error importing messages: ${JSON.stringify(error)}`,
`Gmail: Error importing message ${messageExternalId}: ${JSON.stringify(error)}`,
);
if (isGmailNetworkError(error)) {
throw parseGmailNetworkError(error);
}
if (isGmailApiBatchError(error)) {
const exception = parseGmailApiBatchError(error, messageExternalId);
if (isGmailApiError(error)) {
const status = error.response?.status;
if (!isDefined(exception)) {
// 404/410 means message was deleted - skip silently
if (status === 404 || status === 410) {
return;
}
throw exception;
throw parseGmailApiError(error);
}
throw new MessageImportDriverException(
@@ -1,7 +0,0 @@
export type GmailApiBatchError = {
code: number;
errors: {
reason: string;
message: string;
}[];
};
@@ -1,13 +0,0 @@
import { type gmail_v1 } from 'googleapis';
type GmailMessageError = {
error: {
code: number;
message: string;
status: string;
};
};
export type GmailMessageParsedResponse =
| gmail_v1.Schema$Message
| GmailMessageError;
@@ -1,149 +1,125 @@
import {
MessageImportDriverException,
MessageImportDriverExceptionCode,
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { default as gmailBatchApiErrorMocks } from 'src/modules/messaging/message-import-manager/drivers/gmail/mocks/gmail-batch-api-error-mocks';
import { parseGmailApiBatchError } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-gmail-api-batch-error.util';
import { MessageImportDriverExceptionCode } from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { getGmailApiError } from 'src/modules/messaging/message-import-manager/drivers/gmail/mocks/gmail-api-error-mocks';
import { GmailMessagesImportErrorHandler } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-messages-import-error-handler.service';
const messageExternalId = '123';
describe('parseGmailApiBatchError', () => {
it('should handle 400 Bad Request', () => {
const error = gmailBatchApiErrorMocks.getError(400);
const exception = parseGmailApiBatchError(error, messageExternalId);
describe('GmailMessagesImportErrorHandler', () => {
let handler: GmailMessagesImportErrorHandler;
expect(exception).toBeInstanceOf(MessageImportDriverException);
expect(exception?.code).toBe(MessageImportDriverExceptionCode.UNKNOWN);
expect(exception?.message).toBe(
`${error.errors[0].message} for message with externalId: ${messageExternalId}`,
beforeEach(() => {
handler = new GmailMessagesImportErrorHandler();
});
it('should handle 400 Bad Request', () => {
const error = getGmailApiError({ code: 400 });
expect(() => handler.handleError(error, messageExternalId)).toThrow(
expect.objectContaining({
code: MessageImportDriverExceptionCode.UNKNOWN,
}),
);
});
it('should handle 400 Invalid Grant', () => {
const error = gmailBatchApiErrorMocks.getError(400, 'invalid_grant');
const exception = parseGmailApiBatchError(error, messageExternalId);
const error = getGmailApiError({ code: 400, reason: 'invalid_grant' });
expect(exception).toBeInstanceOf(MessageImportDriverException);
expect(exception?.code).toBe(
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
expect(exception?.message).toBe(
`${error.errors[0].message} for message with externalId: ${messageExternalId}`,
expect(() => handler.handleError(error, messageExternalId)).toThrow(
expect.objectContaining({
code: MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
}),
);
});
it('should handle 400 Failed Precondition', () => {
const error = gmailBatchApiErrorMocks.getError(400, 'failedPrecondition');
const exception = parseGmailApiBatchError(error, messageExternalId);
const error = getGmailApiError({ code: 400, reason: 'failedPrecondition' });
expect(exception).toBeInstanceOf(MessageImportDriverException);
expect(exception?.code).toBe(
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
expect(() => handler.handleError(error, messageExternalId)).toThrow(
expect.objectContaining({
code: MessageImportDriverExceptionCode.TEMPORARY_ERROR,
}),
);
});
it('should handle 401 Invalid Credentials', () => {
const error = gmailBatchApiErrorMocks.getError(401);
const exception = parseGmailApiBatchError(error, messageExternalId);
const error = getGmailApiError({ code: 401 });
expect(exception).toBeInstanceOf(MessageImportDriverException);
expect(exception?.code).toBe(
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
expect(exception?.message).toBe(
`${error.errors[0].message} for message with externalId: ${messageExternalId}`,
expect(() => handler.handleError(error, messageExternalId)).toThrow(
expect.objectContaining({
code: MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
}),
);
});
it('should handle 403 Daily Limit Exceeded', () => {
const error = gmailBatchApiErrorMocks.getError(403, 'dailyLimit');
const exception = parseGmailApiBatchError(error, messageExternalId);
const error = getGmailApiError({ code: 403, reason: 'dailyLimit' });
expect(exception).toBeInstanceOf(MessageImportDriverException);
expect(exception?.code).toBe(
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
expect(exception?.message).toBe(
`${error.errors[0].message} for message with externalId: ${messageExternalId}`,
expect(() => handler.handleError(error, messageExternalId)).toThrow(
expect.objectContaining({
code: MessageImportDriverExceptionCode.TEMPORARY_ERROR,
}),
);
});
it('should handle 403 User Rate Limit Exceeded', () => {
const error = gmailBatchApiErrorMocks.getError(403, 'userRateLimit');
const exception = parseGmailApiBatchError(error, messageExternalId);
const error = getGmailApiError({ code: 403, reason: 'userRateLimit' });
expect(exception).toBeInstanceOf(MessageImportDriverException);
expect(exception?.code).toBe(
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
expect(exception?.message).toBe(
`${error.errors[0].message} for message with externalId: ${messageExternalId}`,
expect(() => handler.handleError(error, messageExternalId)).toThrow(
expect.objectContaining({
code: MessageImportDriverExceptionCode.TEMPORARY_ERROR,
}),
);
});
it('should handle 403 Rate Limit Exceeded', () => {
const error = gmailBatchApiErrorMocks.getError(403, 'rateLimit');
const exception = parseGmailApiBatchError(error, messageExternalId);
const error = getGmailApiError({ code: 403, reason: 'rateLimit' });
expect(exception).toBeInstanceOf(MessageImportDriverException);
expect(exception?.code).toBe(
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
expect(exception?.message).toBe(
`${error.errors[0].message} for message with externalId: ${messageExternalId}`,
expect(() => handler.handleError(error, messageExternalId)).toThrow(
expect.objectContaining({
code: MessageImportDriverExceptionCode.TEMPORARY_ERROR,
}),
);
});
it('should handle 403 Domain Policy Error', () => {
const error = gmailBatchApiErrorMocks.getError(403, 'domainPolicy');
const exception = parseGmailApiBatchError(error, messageExternalId);
const error = getGmailApiError({ code: 403, reason: 'domainPolicy' });
expect(exception).toBeInstanceOf(MessageImportDriverException);
expect(exception?.code).toBe(
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
expect(exception?.message).toBe(
`${error.errors[0].message} for message with externalId: ${messageExternalId}`,
expect(() => handler.handleError(error, messageExternalId)).toThrow(
expect.objectContaining({
code: MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
}),
);
});
it('should handle 404 Not Found', () => {
const error = gmailBatchApiErrorMocks.getError(404);
const exception = parseGmailApiBatchError(error, messageExternalId);
it('should handle 404 Not Found by returning silently', () => {
const error = getGmailApiError({ code: 404 });
expect(exception).toBeUndefined();
expect(() => handler.handleError(error, messageExternalId)).not.toThrow();
});
it('should handle 410 Gone', () => {
const error = gmailBatchApiErrorMocks.getError(410);
const exception = parseGmailApiBatchError(error, messageExternalId);
it('should handle 410 Gone by returning silently', () => {
const error = getGmailApiError({ code: 410 });
expect(exception).toBeUndefined();
expect(() => handler.handleError(error, messageExternalId)).not.toThrow();
});
it('should handle 429 Too Many Requests', () => {
const error = gmailBatchApiErrorMocks.getError(429, 'concurrent');
const exception = parseGmailApiBatchError(error, messageExternalId);
const error = getGmailApiError({ code: 429 });
expect(exception).toBeInstanceOf(MessageImportDriverException);
expect(exception?.code).toBe(
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
expect(exception?.message).toBe(
`${error.errors[0].message} for message with externalId: ${messageExternalId}`,
expect(() => handler.handleError(error, messageExternalId)).toThrow(
expect.objectContaining({
code: MessageImportDriverExceptionCode.TEMPORARY_ERROR,
}),
);
});
it('should handle 500 Backend Error', () => {
const error = gmailBatchApiErrorMocks.getError(500);
const exception = parseGmailApiBatchError(error, messageExternalId);
const error = getGmailApiError({ code: 500 });
expect(exception).toBeInstanceOf(MessageImportDriverException);
expect(exception?.code).toBe(
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
expect(exception?.message).toBe(
`${error.errors[0].message} for message with externalId: ${messageExternalId}`,
expect(() => handler.handleError(error, messageExternalId)).toThrow(
expect.objectContaining({
code: MessageImportDriverExceptionCode.TEMPORARY_ERROR,
}),
);
});
});
@@ -1,9 +0,0 @@
import { type MessageQuery } from 'src/modules/messaging/message-import-manager/types/message-or-thread-query';
export const createQueriesFromMessageIds = (
messageExternalIds: string[],
): MessageQuery[] => {
return messageExternalIds.map((messageId) => ({
uri: '/gmail/v1/users/me/messages/' + messageId + '?format=FULL',
}));
};
@@ -1,21 +0,0 @@
import { type GmailApiBatchError } from 'src/modules/messaging/message-import-manager/drivers/gmail/types/gmail-api-batch-error.type';
export const isGmailApiBatchError = (
error: unknown,
): error is GmailApiBatchError => {
if (error === null || typeof error !== 'object') {
return false;
}
if (
!('code' in error) ||
!('errors' in error) ||
!Array.isArray(error.errors) ||
error.errors.length === 0 ||
error.errors.some((error) => !('reason' in error) || !('message' in error))
) {
return false;
}
return true;
};
@@ -1,112 +0,0 @@
import {
MessageImportDriverException,
MessageImportDriverExceptionCode,
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { type GmailApiBatchError } from 'src/modules/messaging/message-import-manager/drivers/gmail/types/gmail-api-batch-error.type';
export const parseGmailApiBatchError = (
error: GmailApiBatchError,
messageExternalId?: string,
): MessageImportDriverException | undefined => {
const { code, errors } = error;
const reason = errors?.[0]?.reason;
const originalMessage = errors?.[0]?.message;
const message = `${errors?.[0]?.message} for message with externalId: ${messageExternalId}`;
switch (code) {
case 400:
if (reason === 'invalid_grant') {
return new MessageImportDriverException(
message,
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
}
if (reason === 'failedPrecondition') {
if (originalMessage.includes('Mail service not enabled')) {
return new MessageImportDriverException(
message,
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
}
return new MessageImportDriverException(
message,
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
}
return new MessageImportDriverException(
message,
MessageImportDriverExceptionCode.UNKNOWN,
);
case 404:
case 410:
return undefined;
case 429:
return new MessageImportDriverException(
message,
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
case 403:
if (
reason === 'rateLimitExceeded' ||
reason === 'userRateLimitExceeded' ||
reason === 'dailyLimitExceeded'
) {
return new MessageImportDriverException(
message,
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
}
if (reason === 'domainPolicy') {
return new MessageImportDriverException(
message,
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
}
break;
case 401:
return new MessageImportDriverException(
message,
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
case 503:
return new MessageImportDriverException(
message,
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
case 500:
case 502:
case 504:
if (reason === 'backendError') {
return new MessageImportDriverException(
message,
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
}
if (errors?.[0]?.message.includes(`Authentication backend unavailable`)) {
return new MessageImportDriverException(
`${code} - ${reason} - ${message}`,
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
);
}
break;
default:
break;
}
return new MessageImportDriverException(
message,
MessageImportDriverExceptionCode.UNKNOWN,
);
};
@@ -1,5 +0,0 @@
type Query = {
uri: string;
};
export type BatchQueries = Query[];