fix google signup edge case (#18365)

Fixes an edge case when a user signs up with Google and the profile
avatar network request times out, we crash instead of creating the user
without an avatar.

Added `axios-retry` to retry max 2 times and if it still fails we
gracefully skip avatar image instead of crashing

Fixes
Sentry TWENTY-SERVER-FDQ
Sonarly https://sonarly.com/issue/6564

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
neo773
2026-03-05 03:21:55 +05:30
committed by GitHub
parent 911a46aa45
commit 4c001778c2
9 changed files with 211 additions and 65 deletions
@@ -1,9 +1,16 @@
import * as http from 'http';
import * as https from 'https';
import axiosRetry from 'axios-retry';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
jest.mock('axios-retry', () => ({
__esModule: true,
default: jest.fn(),
}));
const createMockConfigService = (
overrides: Record<string, unknown> = {},
): TwentyConfigService => {
@@ -81,6 +88,43 @@ describe('SecureHttpClientService', () => {
expect(client.defaults.baseURL).toBe('https://example.com/api');
});
it('should configure axios-retry when retries is greater than 0', () => {
jest.mocked(axiosRetry).mockClear();
const service = new SecureHttpClientService(createMockConfigService());
const client = service.getHttpClient({
retries: 2,
shouldResetTimeout: true,
});
expect(axiosRetry).toHaveBeenCalledWith(client, {
retries: 2,
shouldResetTimeout: true,
retryCondition: expect.any(Function),
});
});
it('should not configure axios-retry when retries is 0', () => {
jest.mocked(axiosRetry).mockClear();
const service = new SecureHttpClientService(createMockConfigService());
service.getHttpClient({ retries: 0 });
expect(axiosRetry).not.toHaveBeenCalled();
});
it('should not leak retry config into axios defaults', () => {
const service = new SecureHttpClientService(createMockConfigService());
const client = service.getHttpClient({
retries: 2,
shouldResetTimeout: true,
baseURL: 'https://example.com',
});
expect(client.defaults.baseURL).toBe('https://example.com');
expect(client.defaults).not.toHaveProperty('retries');
expect(client.defaults).not.toHaveProperty('shouldResetTimeout');
});
});
describe('getInternalHttpClient', () => {
@@ -1,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import axios, { type AxiosInstance, type CreateAxiosDefaults } from 'axios';
import axiosRetry from 'axios-retry';
import { isDefined } from 'twenty-shared/utils';
import { createSsrfSafeAgent } from 'src/engine/core-modules/secure-http-client/utils/create-ssrf-safe-agent.util';
import { resolveAndValidateHostname } from 'src/engine/core-modules/secure-http-client/utils/resolve-and-validate-hostname.util';
@@ -10,6 +12,11 @@ import { type OutboundRequestContext } from './outbound-request-context.type';
const MAX_REDIRECTS = 5;
type SecureHttpClientConfig = CreateAxiosDefaults & {
retries?: number;
shouldResetTimeout?: boolean;
};
@Injectable()
export class SecureHttpClientService {
private readonly logger = new Logger(SecureHttpClientService.name);
@@ -22,24 +29,37 @@ export class SecureHttpClientService {
// When context is provided, outbound requests are logged with
// workspace/user info for GuardDuty correlation.
getHttpClient(
config?: CreateAxiosDefaults,
config?: SecureHttpClientConfig,
context?: OutboundRequestContext,
): AxiosInstance {
const { retries, shouldResetTimeout, ...axiosConfig } = config ?? {};
const isSafeModeEnabled = this.twentyConfigService.get(
'OUTBOUND_HTTP_SAFE_MODE_ENABLED',
);
const client = isSafeModeEnabled
? axios.create({
...config,
...axiosConfig,
httpAgent: createSsrfSafeAgent('http'),
httpsAgent: createSsrfSafeAgent('https'),
maxRedirects: Math.min(
config?.maxRedirects ?? MAX_REDIRECTS,
axiosConfig.maxRedirects ?? MAX_REDIRECTS,
MAX_REDIRECTS,
),
})
: axios.create(config);
: axios.create(axiosConfig);
if (isDefined(retries) && retries > 0) {
axiosRetry(client, {
retries,
shouldResetTimeout,
retryCondition: (error) =>
axiosRetry.isNetworkOrIdempotentRequestError(error) &&
error.code !== 'ECONNABORTED' &&
error.code !== 'ETIMEDOUT',
});
}
if (context) {
client.interceptors.request.use((requestConfig) => {