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

safe-global / safe-client-gateway / 15021392331

14 May 2025 01:01PM UTC coverage: 90.24% (-0.009%) from 90.249%
15021392331

push

github

web-flow
feat: cache in-flight network requests (#2506)

Promises of network requests are stored in an in-memory cache, returned if there is a parallel request. After the request resolves/rejects, it is cleared from the cache:

- Add caching mechanism to `fetch` factory
- Add appropriate test coverage

3305 of 4002 branches covered (82.58%)

Branch coverage included in aggregate %.

28 of 29 new or added lines in 3 files covered. (96.55%)

6 existing lines in 2 files now uncovered.

11451 of 12350 relevant lines covered (92.72%)

555.02 hits per line

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

94.0
/src/datasources/network/network.module.ts
1
import { Global, Module } from '@nestjs/common';
106✔
2
import { IConfigurationService } from '@/config/configuration.service.interface';
106✔
3
import { FetchNetworkService } from '@/datasources/network/fetch.network.service';
106✔
4
import { NetworkService } from '@/datasources/network/network.service.interface';
106✔
5
import { NetworkResponse } from '@/datasources/network/entities/network.response.entity';
6
import {
106✔
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';
106✔
12
import { LogType } from '@/domain/common/entities/log-type.entity';
106✔
13
import { hashSha1 } from '@/domain/common/utils/utils';
106✔
14

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

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

35
  const request = createRequestFunction(requestTimeout);
10✔
36

37
  if (!cacheInFlightRequests) {
10✔
38
    return request;
8✔
39
  }
40

41
  return createCachedRequestFunction(request, loggingService);
2✔
42
}
43

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

52
    try {
22✔
53
      urlObject = new URL(url);
22✔
54
      response = await fetch(url, {
20✔
55
        ...options,
56
        signal: AbortSignal.timeout(requestTimeout),
57
        keepalive: true,
58
      });
59
    } catch (error) {
60
      throw new NetworkRequestError(urlObject, error);
4✔
61
    }
62

63
    // We validate data so don't need worry about casting `null` response
64
    const data = (await response.json().catch(() => null)) as Raw<T>;
18✔
65

66
    if (!response.ok) {
16✔
67
      throw new NetworkResponseError(urlObject, response, data);
6✔
68
    }
69

70
    return {
10✔
71
      status: response.status,
72
      data,
73
    };
74
  };
75
}
76

77
function createCachedRequestFunction(
78
  request: <T>(
79
    url: string,
80
    options: RequestInit,
81
  ) => Promise<NetworkResponse<T>>,
82
  loggingService: ILoggingService,
83
) {
84
  const cache: Record<string, Promise<NetworkResponse<unknown>>> = {};
2✔
85

86
  return async <T>(
2✔
87
    url: string,
88
    options: RequestInit,
89
  ): Promise<NetworkResponse<T>> => {
90
    const key = getCacheKey(url, options);
20✔
91
    if (key in cache) {
20✔
92
      loggingService.debug({
6✔
93
        type: LogType.ExternalRequestCacheHit,
94
        url,
95
        key,
96
      });
97
    } else {
98
      loggingService.debug({
14✔
99
        type: LogType.ExternalRequestCacheMiss,
100
        url,
101
        key,
102
      });
103

104
      cache[key] = request(url, options)
14✔
105
        .catch((err) => {
106
          loggingService.debug({
4✔
107
            type: LogType.ExternalRequestCacheError,
108
            url,
109
            key,
110
          });
111
          return err;
4✔
112
        })
113
        .finally(() => {
114
          delete cache[key];
14✔
115
        });
116
    }
117

118
    return cache[key];
20✔
119
  };
120
}
121

122
function getCacheKey(url: string, requestInit?: RequestInit): string {
123
  if (!requestInit) {
20!
NEW
124
    return url;
×
125
  }
126

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

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