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

safe-global / safe-client-gateway / 18093700360

29 Sep 2025 10:21AM UTC coverage: 88.954% (-0.1%) from 89.081%
18093700360

Pull #2717

github

katspaugh
Include API version in cache ETags
Pull Request #2717: Feat: Add HTTP caching headers based on Redis TTL

3655 of 4527 branches covered (80.74%)

Branch coverage included in aggregate %.

52 of 70 new or added lines in 8 files covered. (74.29%)

1 existing line in 1 file now uncovered.

12524 of 13661 relevant lines covered (91.68%)

531.28 hits per line

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

91.89
/src/datasources/cache/redis.cache.service.ts
1
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
114✔
2
import { RedisClientType } from '@/datasources/cache/cache.module';
3
import { ICacheService } from '@/datasources/cache/cache.service.interface';
4
import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity';
114✔
5
import { ICacheReadiness } from '@/domain/interfaces/cache-readiness.interface';
6
import { ILoggingService, LoggingService } from '@/logging/logging.interface';
114✔
7
import { IConfigurationService } from '@/config/configuration.service.interface';
114✔
8
import {
114✔
9
  CACHE_INVALIDATION_PREFIX,
10
  CacheKeyPrefix,
11
  MAX_TTL,
12
} from '@/datasources/cache/constants';
13
import { ResponseCacheService } from '@/datasources/cache/response-cache.service';
114✔
14
import { LogType } from '@/domain/common/entities/log-type.entity';
114✔
15
import { deviateRandomlyByPercentage } from '@/domain/common/utils/number';
114✔
16

17
@Injectable()
18
export class RedisCacheService
114✔
19
  implements ICacheService, ICacheReadiness, OnModuleDestroy
20
{
21
  private readonly quitTimeoutInSeconds: number = 2;
44✔
22
  private readonly defaultExpirationTimeInSeconds: number;
23
  private readonly defaultExpirationDeviatePercent: number;
24

25
  constructor(
26
    @Inject('RedisClient') private readonly client: RedisClientType,
44✔
27
    @Inject(LoggingService) private readonly loggingService: ILoggingService,
44✔
28
    @Inject(IConfigurationService)
29
    private readonly configurationService: IConfigurationService,
44✔
30
    @Inject(CacheKeyPrefix) private readonly keyPrefix: string,
44✔
31
    private readonly responseCacheService: ResponseCacheService,
44✔
32
  ) {
33
    this.defaultExpirationTimeInSeconds =
44✔
34
      this.configurationService.getOrThrow<number>(
35
        'expirationTimeInSeconds.default',
36
      );
37
    this.defaultExpirationDeviatePercent =
44✔
38
      this.configurationService.getOrThrow<number>(
39
        'expirationTimeInSeconds.deviatePercent',
40
      );
41
  }
42

43
  async ping(): Promise<unknown> {
44
    return this.client.ping();
2✔
45
  }
46

47
  ready(): boolean {
48
    return this.client.isReady;
×
49
  }
50

51
  async getCounter(key: string): Promise<number | null> {
52
    const value = await this.client.get(this._prefixKey(key));
10✔
53
    const numericValue = Number(value);
10✔
54
    return Number.isInteger(numericValue) ? numericValue : null;
10✔
55
  }
56

57
  async hSet(
58
    cacheDir: CacheDir,
59
    value: string,
60
    expireTimeSeconds: number | undefined,
61
    expireDeviatePercent?: number,
62
    options?: { trackTtl?: boolean },
63
  ): Promise<void> {
64
    if (!expireTimeSeconds || expireTimeSeconds <= 0) {
26✔
65
      return;
2✔
66
    }
67

68
    const key = this._prefixKey(cacheDir.key);
24✔
69
    const expirationTime = this.enforceMaxRedisTTL(
24✔
70
      deviateRandomlyByPercentage(
71
        expireTimeSeconds,
72
        expireDeviatePercent ?? this.defaultExpirationDeviatePercent,
36✔
73
      ),
74
    );
75

76
    try {
24✔
77
      await this.client.hSet(key, cacheDir.field, value);
24✔
78
      // NX - Set expiry only when the key has no expiry
79
      // See https://redis.io/commands/expire/
80
      await this.client.expire(key, expirationTime, 'NX');
24✔
81
      if (this.shouldTrackTtl(cacheDir) && options?.trackTtl !== false) {
24!
82
        await this.trackTtl(cacheDir);
16✔
83
      }
84
    } catch (error) {
85
      this.loggingService.error({
×
86
        type: LogType.CacheError,
87
        source: 'RedisCacheService',
88
        event: `Error setting/expiring ${key}:${cacheDir.field}`,
89
      });
90
      await this.client.unlink(key);
×
91
      throw error;
×
92
    }
93
  }
94

95
  async hGet(cacheDir: CacheDir): Promise<string | undefined> {
96
    const key = this._prefixKey(cacheDir.key);
8✔
97
    const value = await this.client.hGet(key, cacheDir.field);
8✔
98
    if (value != null && this.shouldTrackTtl(cacheDir)) {
8✔
99
      await this.trackTtl(cacheDir);
2✔
100
    }
101
    return value ?? undefined;
8✔
102
  }
103

104
  async deleteByKey(key: string): Promise<number> {
105
    const keyWithPrefix = this._prefixKey(key);
8✔
106
    // see https://redis.io/commands/unlink/
107
    const result = await this.client.unlink(keyWithPrefix);
8✔
108

109
    await this.hSet(
8✔
110
      new CacheDir(`${CACHE_INVALIDATION_PREFIX}${key}`, ''),
111
      Date.now().toString(),
112
      this.defaultExpirationTimeInSeconds,
113
      0,
114
      { trackTtl: false },
115
    );
116
    return result;
8✔
117
  }
118

119
  async increment(
120
    cacheKey: string,
121
    expireTimeSeconds: number | undefined,
122
    expireDeviatePercent?: number,
123
  ): Promise<number> {
124
    const transaction = this.client.multi().incr(cacheKey);
26✔
125
    if (expireTimeSeconds !== undefined && expireTimeSeconds > 0) {
26✔
126
      const expirationTime = this.enforceMaxRedisTTL(
4✔
127
        deviateRandomlyByPercentage(
128
          expireTimeSeconds,
129
          expireDeviatePercent ?? this.defaultExpirationDeviatePercent,
6✔
130
        ),
131
      );
132

133
      transaction.expire(cacheKey, expirationTime, 'NX');
4✔
134
    }
135
    const [incrRes] = await transaction.get(cacheKey).exec();
26✔
136
    return Number(incrRes);
26✔
137
  }
138

139
  async setCounter(
140
    key: string,
141
    value: number,
142
    expireTimeSeconds: number,
143
    expireDeviatePercent?: number,
144
  ): Promise<void> {
145
    const expirationTime = this.enforceMaxRedisTTL(
8✔
146
      deviateRandomlyByPercentage(
147
        expireTimeSeconds,
148
        expireDeviatePercent ?? this.defaultExpirationDeviatePercent,
12✔
149
      ),
150
    );
151

152
    await this.client.set(key, value, {
8✔
153
      EX: expirationTime,
154
      NX: true,
155
    });
156
  }
157

158
  /**
159
   * Constructs a prefixed key string.
160
   *
161
   * This function takes a key string as an input and prefixes it with `this.keyPrefix`.
162
   * If `this.keyPrefix` is empty, it returns the original key without any prefix.
163
   *
164
   * @param key - The original key string that needs to be prefixed.
165
   * @returns A string that combines `this.keyPrefix` and the original `key` with a hyphen.
166
   *          If `this.keyPrefix` is empty, the original `key` is returned without any modification.
167
   * @private
168
   */
169
  private _prefixKey(key: string): string {
170
    if (this.keyPrefix.length === 0) {
68✔
171
      return key;
58✔
172
    }
173

174
    return `${this.keyPrefix}-${key}`;
10✔
175
  }
176

177
  /**
178
   * Closes the connection to Redis when the module associated with this service
179
   * is destroyed. This tries to gracefully close the connection. If the Redis
180
   * instance is not responding it invokes {@link forceQuit}.
181
   */
182
  async onModuleDestroy(): Promise<void> {
183
    this.loggingService.warn({
6✔
184
      type: LogType.CacheEvent,
185
      source: 'RedisCacheService',
186
      event: 'Closing Redis connection',
187
    });
188
    const forceQuitTimeout = setTimeout(() => {
6✔
189
      this.forceQuit.bind(this);
×
190
    }, this.quitTimeoutInSeconds * 1000);
191
    await this.client.quit();
6✔
192
    clearTimeout(forceQuitTimeout);
6✔
193
  }
194

195
  /**
196
   * Forces the closing of the Redis connection associated with this service.
197
   */
198
  private async forceQuit(): Promise<void> {
199
    this.loggingService.warn({
×
200
      type: LogType.CacheEvent,
201
      source: 'RedisCacheService',
202
      event: 'Forcing Redis connection close',
203
    });
204
    await this.client.disconnect();
×
205
  }
206

207
  /**
208
   * Enforces the maximum TTL for Redis to prevent overflow errors.
209
   *
210
   * @param {number} ttl - The TTL to enforce.
211
   *
212
   * @returns {number} The TTL if it is less than or equal to MAX_TTL, otherwise MAX_TTL.
213
   */
214
  private enforceMaxRedisTTL(ttl: number): number {
215
    return Math.min(ttl, MAX_TTL);
36✔
216
  }
217

218
  private shouldTrackTtl(cacheDir: CacheDir): boolean {
219
    return !cacheDir.key.startsWith(CACHE_INVALIDATION_PREFIX);
26✔
220
  }
221

222
  private async trackTtl(cacheDir: CacheDir): Promise<void> {
223
    try {
18✔
224
      const ttl = await this.client.ttl(this._prefixKey(cacheDir.key));
18✔
225
      this.responseCacheService.trackTtl(ttl > 0 ? ttl : null);
18✔
226
    } catch (error) {
NEW
227
      this.loggingService.warn({
×
228
        type: LogType.CacheEvent,
229
        source: 'RedisCacheService',
230
        event: `Failed to read TTL for ${cacheDir.key}`,
231
        error,
232
      });
233
    }
234
  }
235
}
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