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

safe-global / safe-client-gateway / 19960343122

05 Dec 2025 10:36AM UTC coverage: 90.195% (+0.01%) from 90.183%
19960343122

Pull #2831

github

gjeanmart
feat(network): add per-request timeout support via NetworkRequest

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

Changes:
- Add HTTP_CLIENT_REQUEST_TIMEOUT_MILLISECONDS_OWNERS env variable
  mapped to httpClient.ownersTimeout configuration
- Add optional timeout field to NetworkRequest interface
- Update FetchNetworkService to create AbortSignal from timeout
  when provided in NetworkRequest
- Update NetworkModule to use custom signal if present, otherwise
  fall back to default timeout
- Add comprehensive tests for timeout functionality

This enables fine-grained timeout control for specific endpoints
that may require different timeout values than the global default.
Pull Request #2831: feat(network): add per-request timeout support via NetworkRequest

2864 of 3557 branches covered (80.52%)

Branch coverage included in aggregate %.

10 of 10 new or added lines in 2 files covered. (100.0%)

13 existing lines in 3 files now uncovered.

13777 of 14893 relevant lines covered (92.51%)

631.9 hits per line

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

94.44
/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
) => Promise<NetworkResponse<T>>;
19

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

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

37
  const request = createRequestFunction(requestTimeout);
8✔
38

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

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

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

54
    try {
34✔
55
      urlObject = new URL(url);
34✔
56
      // Use custom signal if present, otherwise use default timeout
57
      const signal = options.signal ?? AbortSignal.timeout(requestTimeout);
32✔
58
      response = await fetch(url, {
32✔
59
        ...options,
60
        signal,
61
        keepalive: true,
62
      });
63
    } catch (error) {
64
      throw new NetworkRequestError(urlObject, error);
8✔
65
    }
66

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

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

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

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

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

120
    return cache[key];
28✔
121
  };
122
}
123

124
function getCacheKey(url: string, requestInit?: RequestInit): string {
125
  if (!requestInit) {
28!
UNCOV
126
    return url;
×
127
  }
128

129
  // JSON.stringify does not produce a stable key but initially
130
  // use a naive implementation for testing the implementation
131
  // TODO: Revisit this and use a more stable key
132
  const key = JSON.stringify({ url, ...requestInit });
28✔
133
  return hashSha1(key);
28✔
134
}
135

136
/**
137
 * A {@link Global} Module which provides HTTP support via {@link NetworkService}
138
 * Feature Modules don't need to import this module directly in order to inject
139
 * the {@link NetworkService}.
140
 *
141
 * This module should be included in the "root" application module
142
 */
143
@Global()
144
@Module({
145
  providers: [
146
    {
147
      provide: 'FetchClient',
148
      useFactory: fetchClientFactory,
149
      inject: [IConfigurationService, LoggingService],
150
    },
151
    { provide: NetworkService, useClass: FetchNetworkService },
152
  ],
153
  exports: [NetworkService],
154
})
155
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