• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

safe-global / safe-client-gateway / 20025855031

08 Dec 2025 11:02AM UTC coverage: 90.197% (-0.001%) from 90.198%
20025855031

push

github

web-flow
feat(network): add per-request timeout support via NetworkRequest (#2831)

This PR adds support for custom timeout values per HTTP request through the NetworkRequest interface. This enables different endpoints to use different timeout values while maintaining a default timeout for all requests.

2858 of 3551 branches covered (80.48%)

Branch coverage included in aggregate %.

11 of 11 new or added lines in 3 files covered. (100.0%)

2 existing lines in 2 files now uncovered.

13786 of 14902 relevant lines covered (92.51%)

631.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.55
/src/datasources/network/network.module.ts
1
import { Global, Module } from '@nestjs/common';
108✔
2
import { IConfigurationService } from '@/config/configuration.service.interface';
108✔
3
import { FetchNetworkService } from '@/datasources/network/fetch.network.service';
108✔
4
import { NetworkService } from '@/datasources/network/network.service.interface';
108✔
5
import { NetworkResponse } from '@/datasources/network/entities/network.response.entity';
6
import {
108✔
7
  NetworkRequestError,
8
  NetworkResponseError,
9
} from '@/datasources/network/entities/network.error.entity';
10
import type { Raw } from '@/validation/entities/raw.entity';
11
import { ILoggingService, LoggingService } from '@/logging/logging.interface';
108✔
12
import { LogType } from '@/domain/common/entities/log-type.entity';
108✔
13
import { hashSha1 } from '@/domain/common/utils/utils';
108✔
14

15
export type FetchClient = <T>(
16
  url: string,
17
  options: RequestInit,
18
  timeout?: number,
19
) => Promise<NetworkResponse<T>>;
20

21
const cache: Record<string, Promise<NetworkResponse<unknown>>> = {};
108✔
22

23
/**
24
 * Use this factory to create a {@link FetchClient} instance
25
 * that can be used to make HTTP requests.
26
 */
27
function fetchClientFactory(
28
  configurationService: IConfigurationService,
29
  loggingService: ILoggingService,
30
): FetchClient {
31
  const cacheInFlightRequests = configurationService.getOrThrow<boolean>(
8✔
32
    'features.cacheInFlightRequests',
33
  );
34
  const defaultTimeout = configurationService.getOrThrow<number>(
8✔
35
    'httpClient.requestTimeout',
36
  );
37

38
  const request = createRequestFunction(defaultTimeout);
8✔
39

40
  if (!cacheInFlightRequests) {
8✔
41
    return request;
6✔
42
  }
43

44
  return createCachedRequestFunction(request, loggingService);
2✔
45
}
46

47
function createRequestFunction(defaultTimeout: number) {
48
  return async <T>(
8✔
49
    url: string,
50
    options: RequestInit,
51
    customTimeout?: number,
52
  ): Promise<NetworkResponse<T>> => {
53
    let urlObject: URL | null = null;
34✔
54
    let response: Response | null = null;
34✔
55

56
    try {
34✔
57
      urlObject = new URL(url);
34✔
58
      const timeout = customTimeout ?? defaultTimeout;
32✔
59

60
      response = await fetch(url, {
32✔
61
        ...options,
62
        signal: AbortSignal.timeout(timeout),
63
        keepalive: true,
64
      });
65
    } catch (error) {
66
      throw new NetworkRequestError(urlObject, error);
8✔
67
    }
68

69
    // We validate data so don't need worry about casting `null` response
70
    const data = (await response.json().catch(() => null)) as Raw<T>;
26✔
71

72
    if (!response.ok) {
24✔
73
      throw new NetworkResponseError(urlObject, response, data);
14✔
74
    }
75

76
    return {
10✔
77
      status: response.status,
78
      data,
79
    };
80
  };
81
}
82

83
function createCachedRequestFunction(
84
  request: <T>(
85
    url: string,
86
    options: RequestInit,
87
    timeout?: number,
88
  ) => Promise<NetworkResponse<T>>,
89
  loggingService: ILoggingService,
90
) {
91
  return async <T>(
2✔
92
    url: string,
93
    options: RequestInit,
94
    timeout?: number,
95
  ): Promise<NetworkResponse<T>> => {
96
    const key = getCacheKey(url, options, timeout);
28✔
97
    if (key in cache) {
28✔
98
      loggingService.debug({
6✔
99
        type: LogType.ExternalRequestCacheHit,
100
        url,
101
        key,
102
      });
103
    } else {
104
      loggingService.debug({
22✔
105
        type: LogType.ExternalRequestCacheMiss,
106
        url,
107
        key,
108
      });
109

110
      cache[key] = request(url, options, timeout)
22✔
111
        .catch((err) => {
112
          loggingService.debug({
12✔
113
            type: LogType.ExternalRequestCacheError,
114
            url,
115
            key,
116
          });
117
          throw err;
12✔
118
        })
119
        .finally(() => {
120
          delete cache[key];
22✔
121
        });
122
    }
123

124
    return cache[key];
28✔
125
  };
126
}
127

128
function getCacheKey(
129
  url: string,
130
  requestInit?: RequestInit,
131
  timeout?: number,
132
): string {
133
  if (!requestInit && timeout === undefined) {
28!
UNCOV
134
    return url;
×
135
  }
136

137
  // JSON.stringify does not produce a stable key but initially
138
  // use a naive implementation for testing the implementation
139
  // TODO: Revisit this and use a more stable key
140
  const key = JSON.stringify({ url, ...requestInit, timeout });
28✔
141
  return hashSha1(key);
28✔
142
}
143

144
/**
145
 * A {@link Global} Module which provides HTTP support via {@link NetworkService}
146
 * Feature Modules don't need to import this module directly in order to inject
147
 * the {@link NetworkService}.
148
 *
149
 * This module should be included in the "root" application module
150
 */
151
@Global()
152
@Module({
153
  providers: [
154
    {
155
      provide: 'FetchClient',
156
      useFactory: fetchClientFactory,
157
      inject: [IConfigurationService, LoggingService],
158
    },
159
    { provide: NetworkService, useClass: FetchNetworkService },
160
  ],
161
  exports: [NetworkService],
162
})
163
export class NetworkModule {}
108✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc