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

safe-global / safe-client-gateway / 20109578721

10 Dec 2025 06:42PM UTC coverage: 88.839% (-0.2%) from 89.036%
20109578721

Pull #2803

github

PooyaRaki
refactor(circuit-breaker): Attach circuit breaker to the network call
Pull Request #2803: feat(circuit-breaker): implement circuit breaker pattern for resilience

2874 of 3618 branches covered (79.44%)

Branch coverage included in aggregate %.

78 of 104 new or added lines in 6 files covered. (75.0%)

5 existing lines in 3 files now uncovered.

13691 of 15028 relevant lines covered (91.1%)

561.58 hits per line

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

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

17
export type FetchClient = <T>(
18
  url: string,
19
  options: RequestInit,
20
  timeout?: number,
21
  useCircuitBreaker?: boolean,
22
) => Promise<NetworkResponse<T>>;
23

24
const cache: Record<string, Promise<NetworkResponse<unknown>>> = {};
94✔
25

26
/**
27
 * Use this factory to create a {@link FetchClient} instance
28
 * that can be used to make HTTP requests.
29
 */
30
function fetchClientFactory(
31
  configurationService: IConfigurationService,
32
  circuitBreakerService: CircuitBreakerService,
33
  loggingService: ILoggingService,
34
): FetchClient {
35
  const cacheInFlightRequests = configurationService.getOrThrow<boolean>(
4✔
36
    'features.cacheInFlightRequests',
37
  );
38
  const defaultTimeout = configurationService.getOrThrow<number>(
4✔
39
    'httpClient.requestTimeout',
40
  );
41
  const circuitBreakerEnabledByDefault =
42
    configurationService.getOrThrow<boolean>('circuitBreaker.enabled');
4✔
43

44
  const baseRequest = createRequestFunction(defaultTimeout);
4✔
45
  const circuitBreakerRequest = createCircuitBreakerRequestFunction(
4✔
46
    baseRequest,
47
    circuitBreakerService,
48
    circuitBreakerEnabledByDefault,
49
  );
50

51
  if (!cacheInFlightRequests) {
4✔
52
    return circuitBreakerRequest;
2✔
53
  }
54

55
  return createCachedRequestFunction(circuitBreakerRequest, loggingService);
2✔
56
}
57

58
function createRequestFunction(defaultTimeout: number) {
59
  return async <T>(
4✔
60
    url: string,
61
    options: RequestInit,
62
    customTimeout?: number,
63
  ): Promise<NetworkResponse<T>> => {
64
    let urlObject: URL | null = null;
34✔
65
    let response: Response | null = null;
34✔
66

67
    try {
34✔
68
      urlObject = new URL(url);
34✔
69
      const timeout = customTimeout ?? defaultTimeout;
32✔
70

71
      response = await fetch(url, {
32✔
72
        ...options,
73
        signal: AbortSignal.timeout(timeout),
74
        keepalive: true,
75
      });
76
    } catch (error) {
77
      throw new NetworkRequestError(urlObject, error);
8✔
78
    }
79

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

83
    if (!response.ok) {
24✔
84
      throw new NetworkResponseError(urlObject, response, data);
14✔
85
    }
86

87
    return {
10✔
88
      status: response.status,
89
      data,
90
    };
91
  };
92
}
93

94
/**
95
 * Wraps a request function with circuit breaker logic
96
 *
97
 * This function intercepts requests and applies circuit breaker protection:
98
 * - Checks if the circuit is open before allowing the request
99
 * - Records successes and failures based on response status
100
 * - Can be enabled/disabled per request via the useCircuitBreaker parameter
101
 *
102
 * @param request - The base request function to wrap
103
 * @param circuitBreakerService - Service managing circuit breaker state
104
 * @param enabledByDefault - Whether circuit breaker is enabled by default
105
 * @returns Wrapped request function with circuit breaker logic
106
 */
107
function createCircuitBreakerRequestFunction(
108
  request: <T>(
109
    url: string,
110
    options: RequestInit,
111
    timeout?: number,
112
  ) => Promise<NetworkResponse<T>>,
113
  circuitBreakerService: CircuitBreakerService,
114
  enabledByDefault: boolean,
115
) {
116
  return async <T>(
4✔
117
    url: string,
118
    options: RequestInit,
119
    timeout?: number,
120
    useCircuitBreaker?: boolean,
121
  ): Promise<NetworkResponse<T>> => {
122
    const shouldUseCircuitBreaker = useCircuitBreaker ?? enabledByDefault;
34✔
123

124
    if (!shouldUseCircuitBreaker) {
34✔
125
      return request(url, options, timeout);
34✔
126
    }
127

NEW
128
    circuitBreakerService.canProceedOrFail(url);
×
129

NEW
130
    try {
×
NEW
131
      const response = await request(url, options, timeout);
×
NEW
132
      circuitBreakerService.recordSuccess(url);
×
133

NEW
134
      return response;
×
135
    } catch (error) {
NEW
136
      if (
×
137
        (error instanceof NetworkResponseError &&
×
138
          error.response.status >= 500) ||
139
        error instanceof CircuitBreakerException
140
      ) {
NEW
141
        const circuit = circuitBreakerService.getOrRegisterCircuit(url);
×
NEW
142
        circuitBreakerService.recordFailure(circuit);
×
143
      }
144

UNCOV
145
      throw error;
×
146
    }
147
  };
148
}
149

150
function createCachedRequestFunction(
151
  request: <T>(
152
    url: string,
153
    options: RequestInit,
154
    timeout?: number,
155
    useCircuitBreaker?: boolean,
156
  ) => Promise<NetworkResponse<T>>,
157
  loggingService: ILoggingService,
158
) {
159
  return async <T>(
2✔
160
    url: string,
161
    options: RequestInit,
162
    timeout?: number,
163
    useCircuitBreaker?: boolean,
164
  ): Promise<NetworkResponse<T>> => {
165
    const key = getCacheKey(url, options, timeout);
28✔
166
    if (key in cache) {
28✔
167
      loggingService.debug({
6✔
168
        type: LogType.ExternalRequestCacheHit,
169
        url,
170
        key,
171
      });
172
    } else {
173
      loggingService.debug({
22✔
174
        type: LogType.ExternalRequestCacheMiss,
175
        url,
176
        key,
177
      });
178

179
      cache[key] = request(url, options, timeout, useCircuitBreaker)
22✔
180
        .catch((err) => {
181
          loggingService.debug({
12✔
182
            type: LogType.ExternalRequestCacheError,
183
            url,
184
            key,
185
          });
186
          throw err;
12✔
187
        })
188
        .finally(() => {
189
          delete cache[key];
22✔
190
        });
191
    }
192

193
    return cache[key];
28✔
194
  };
195
}
196

197
function getCacheKey(
198
  url: string,
199
  requestInit?: RequestInit,
200
  timeout?: number,
201
  useCircuitBreaker?: boolean,
202
): string {
203
  if (
28!
204
    !requestInit &&
14!
205
    timeout === undefined &&
206
    useCircuitBreaker === undefined
207
  ) {
NEW
208
    return url;
×
209
  }
210

211
  // JSON.stringify does not produce a stable key but initially
212
  // use a naive implementation for testing the implementation
213
  // TODO: Revisit this and use a more stable key
214
  const key = JSON.stringify({
28✔
215
    url,
216
    ...requestInit,
217
    timeout,
218
    useCircuitBreaker,
219
  });
220
  return hashSha1(key);
28✔
221
}
222

223
/**
224
 * A {@link Global} Module which provides HTTP support via {@link NetworkService}
225
 * Feature Modules don't need to import this module directly in order to inject
226
 * the {@link NetworkService}.
227
 *
228
 * This module should be included in the "root" application module
229
 */
230
@Global()
231
@Module({
232
  providers: [
233
    {
234
      provide: 'FetchClient',
235
      useFactory: fetchClientFactory,
236
      inject: [IConfigurationService, CircuitBreakerService, LoggingService],
237
    },
238
    { provide: NetworkService, useClass: FetchNetworkService },
239
  ],
240
  exports: [NetworkService],
241
})
242
export class NetworkModule {}
94✔
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