Skip to content

Commit a1f20f3

Browse files
committed
feat: Improve handling of HTTP/HTTPS agent sessions
1 parent af5e1e3 commit a1f20f3

7 files changed

Lines changed: 240 additions & 19 deletions

File tree

.changeset/heavy-foxes-deny.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@sap-cloud-sdk/connectivity': minor
3+
'@sap-cloud-sdk/http-client': minor
4+
---
5+
6+
[Improvement] Cache custom http and https agents and enable keep-alive by default

.changeset/swift-candies-bake.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@sap-cloud-sdk/connectivity': minor
3+
'@sap-cloud-sdk/http-client': minor
4+
---
5+
6+
[Improvement] Use node's global http/https agent unless a custom agent is required by the destination configuration.

packages/connectivity/src/http-agent/http-agent.spec.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jest.mock('jks-js', () => ({
99
import * as jks from 'jks-js';
1010
import { registerDestinationCache } from '../scp-cf/destination/register-destination-cache';
1111
import { certAsString } from '../../../../test-resources/test/test-util/test-certificate';
12-
import { getAgentConfig } from './http-agent';
12+
import { agentCreateCache, getAgentConfig } from './http-agent';
1313
import type { HttpDestination } from '../scp-cf/destination';
1414
import type { DestinationCertificate } from '../scp-cf';
1515

@@ -257,6 +257,53 @@ describe('createAgent', () => {
257257
// Check coverage
258258
});
259259

260+
describe('agent caching', () => {
261+
beforeEach(() => {
262+
agentCreateCache.clear();
263+
});
264+
265+
it('returns the same agent instance for the same destination options', async () => {
266+
const destination: HttpDestination = { url: 'https://example.com' };
267+
const first = (await getAgentConfig(destination)) as any;
268+
const second = (await getAgentConfig(destination)) as any;
269+
expect(first['httpsAgent']).toBe(second['httpsAgent']);
270+
});
271+
272+
it('returns different agent instances for different destinations', async () => {
273+
const destA: HttpDestination = { url: 'https://a.example.com' };
274+
const destB: HttpDestination = {
275+
url: 'https://b.example.com',
276+
isTrustingAllCertificates: true
277+
};
278+
const agentA = ((await getAgentConfig(destA)) as any)['httpsAgent'];
279+
const agentB = ((await getAgentConfig(destB)) as any)['httpsAgent'];
280+
expect(agentA).not.toBe(agentB);
281+
});
282+
283+
it('enables keepAlive by default on created agents', async () => {
284+
const destination: HttpDestination = { url: 'https://example.com' };
285+
const agent = ((await getAgentConfig(destination)) as any)['httpsAgent'];
286+
expect(agent.keepAlive).toBe(true);
287+
});
288+
289+
it('keepAlive: false in options overrides the default (spread order)', () => {
290+
// createAgent uses { keepAlive: true, ...options }
291+
// verify that explicit keepAlive: false in options wins
292+
const merged = { keepAlive: true, ...({ keepAlive: false } as any) };
293+
expect(merged.keepAlive).toBe(false);
294+
});
295+
296+
it('http and https destinations use separate cache entries and agent types', async () => {
297+
const destHttps: HttpDestination = { url: 'https://example.com' };
298+
const destHttp: HttpDestination = { url: 'http://example.com' };
299+
const httpsResult = (await getAgentConfig(destHttps)) as any;
300+
const httpResult = (await getAgentConfig(destHttp)) as any;
301+
expect(httpsResult['httpsAgent']).toBeDefined();
302+
expect(httpResult['httpAgent']).toBeDefined();
303+
expect(httpsResult['httpsAgent']).not.toBe(httpResult['httpAgent']);
304+
});
305+
});
306+
260307
describe('getAgentConfig', () => {
261308
it('returns an object with key "httpsAgent" for destinations with protocol HTTPS', async () => {
262309
const destination: HttpDestination = {

packages/connectivity/src/http-agent/http-agent.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { createLogger, last } from '@sap-cloud-sdk/util';
66
/* Careful the proxy imports cause circular dependencies if imported from scp directly */
77
// eslint-disable-next-line import/no-internal-modules
88
import { getProtocolOrDefault } from '../scp-cf/get-protocol';
9+
// eslint-disable-next-line import/no-internal-modules
10+
import { Cache } from '../scp-cf/cache';
911
import {
1012
addProxyConfigurationInternet,
1113
getProxyConfig,
@@ -15,11 +17,13 @@ import {
1517
// eslint-disable-next-line import/no-internal-modules
1618
import { registerDestinationCache } from '../scp-cf/destination/register-destination-cache';
1719
import type {
18-
BasicProxyConfiguration,
1920
Destination,
2021
DestinationCertificate,
2122
HttpDestination
22-
} from '../scp-cf';
23+
// eslint-disable-next-line import/no-internal-modules
24+
} from '../scp-cf/destination';
25+
// eslint-disable-next-line import/no-internal-modules
26+
import type { BasicProxyConfiguration } from '../scp-cf/connectivity-service-types';
2327
import type { HttpAgentConfig, HttpsAgentConfig } from './agent-config';
2428

2529
const logger = createLogger({
@@ -283,17 +287,40 @@ function validateFormat(certificate: DestinationCertificate) {
283287
}
284288
}
285289

290+
/**
291+
* Cache for http(s) agents.
292+
* Exported for testing purposes only.
293+
* @internal
294+
*/
295+
export const agentCreateCache = new Cache<HttpAgentConfig | HttpsAgentConfig>(
296+
3600000, // 1 hour
297+
100 // max 100 LRU-cached agents
298+
);
299+
286300
/**
287301
* @internal
302+
* Agents are cache for up to one hour, but can be evicted earlier if more than 100 agents are created.
288303
* See https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener for details on the possible options
289304
*/
290305
function createAgent(
291306
destination: HttpDestination,
292307
options: https.AgentOptions
293308
): HttpAgentConfig | HttpsAgentConfig {
294-
return getProtocolOrDefault(destination) === 'https'
295-
? { httpsAgent: new https.Agent(options) }
296-
: { httpAgent: new http.Agent(options) };
309+
const protocol = getProtocolOrDefault(destination);
310+
const cacheKey = JSON.stringify({ protocol, options });
311+
312+
return agentCreateCache.getOrInsertComputed(cacheKey, () => {
313+
logger.debug(
314+
`Creating new ${protocol.toUpperCase()} agent for destination ${destination.name || ''} with options: ${JSON.stringify(options)}`
315+
);
316+
const optionsWithDefaults = { keepAlive: true, ...options };
317+
const entry =
318+
protocol === 'https'
319+
? { httpsAgent: new https.Agent(optionsWithDefaults) }
320+
: { httpAgent: new http.Agent(optionsWithDefaults) };
321+
322+
return { entry };
323+
});
297324
}
298325

299326
/**

packages/connectivity/src/scp-cf/cache.spec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,90 @@ describe('Cache', () => {
9797
const actual = cacheOne.get(undefined);
9898
expect(actual).toBeUndefined();
9999
});
100+
101+
it('item should be retrievable multiple times (non-LRU cache)', () => {
102+
cacheOne.set('multi', { entry: destinationOne });
103+
expect(cacheOne.get('multi')).toEqual(destinationOne);
104+
expect(cacheOne.get('multi')).toEqual(destinationOne);
105+
});
106+
107+
describe('LRU eviction (maxSize)', () => {
108+
it('evicts the least recently used entry when maxSize is exceeded', () => {
109+
const lruCache = new Cache<string>(0, 2);
110+
lruCache.set('a', { entry: 'A' });
111+
lruCache.set('b', { entry: 'B' });
112+
lruCache.set('c', { entry: 'C' }); // should evict 'a'
113+
expect(lruCache.get('a')).toBeUndefined();
114+
expect(lruCache.get('b')).toEqual('B');
115+
expect(lruCache.get('c')).toEqual('C');
116+
});
117+
118+
it('updates LRU order on get, so recently accessed entry is not evicted', () => {
119+
const lruCache = new Cache<string>(0, 2);
120+
lruCache.set('a', { entry: 'A' });
121+
lruCache.set('b', { entry: 'B' });
122+
lruCache.get('a'); // 'a' is now most recently used; 'b' becomes LRU
123+
lruCache.set('c', { entry: 'C' }); // should evict 'b'
124+
expect(lruCache.get('b')).toBeUndefined();
125+
expect(lruCache.get('a')).toEqual('A');
126+
expect(lruCache.get('c')).toEqual('C');
127+
});
128+
129+
it('repeated get keeps the accessed key as most recently used', () => {
130+
const lruCache = new Cache<string>(0, 2);
131+
lruCache.set('a', { entry: 'A' });
132+
lruCache.set('b', { entry: 'B' });
133+
134+
lruCache.get('a');
135+
lruCache.get('a');
136+
lruCache.set('c', { entry: 'C' });
137+
138+
expect(lruCache.get('a')).toEqual('A');
139+
expect(lruCache.get('b')).toBeUndefined();
140+
expect(lruCache.get('c')).toEqual('C');
141+
});
142+
143+
it('does not evict another entry when updating an existing key', () => {
144+
const lruCache = new Cache<string>(0, 2);
145+
lruCache.set('a', { entry: 'A' });
146+
lruCache.set('b', { entry: 'B' });
147+
148+
lruCache.set('b', { entry: 'B updated' });
149+
150+
expect(lruCache.get('a')).toEqual('A');
151+
expect(lruCache.get('b')).toEqual('B updated');
152+
});
153+
});
154+
155+
describe('getOrInsertComputed', () => {
156+
it('calls computeFn on cache miss and stores result', () => {
157+
const compute = jest.fn().mockReturnValue({ entry: destinationOne });
158+
const result = cacheOne.getOrInsertComputed('new', compute);
159+
expect(result).toEqual(destinationOne);
160+
expect(compute).toHaveBeenCalledTimes(1);
161+
});
162+
163+
it('returns cached value on subsequent calls without calling computeFn again', () => {
164+
const compute = jest.fn().mockReturnValue({ entry: destinationOne });
165+
cacheOne.getOrInsertComputed('cached', compute);
166+
const result = cacheOne.getOrInsertComputed('cached', compute);
167+
expect(result).toEqual(destinationOne);
168+
expect(compute).toHaveBeenCalledTimes(1);
169+
});
170+
171+
it('stores a custom expiration returned by computeFn', () => {
172+
jest.useFakeTimers();
173+
const timeToExpire = 5000;
174+
const compute = jest.fn().mockReturnValue({
175+
entry: destinationOne,
176+
expires: Date.now() + timeToExpire
177+
});
178+
179+
cacheOne.getOrInsertComputed('custom-expire', compute);
180+
jest.advanceTimersByTime(timeToExpire + 1);
181+
182+
expect(cacheOne.get('custom-expire')).toBeUndefined();
183+
expect(compute).toHaveBeenCalledTimes(1);
184+
});
185+
});
100186
});

packages/connectivity/src/scp-cf/cache.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ interface CacheInterface<T> {
33
get(key: string | undefined): T | undefined;
44
set(key: string | undefined, item: CacheEntry<T>): void;
55
clear(): void;
6+
getOrInsertComputed(
7+
key: string | undefined,
8+
computeFn: () => CacheEntry<T>
9+
): T;
610
}
711

812
/**
@@ -39,21 +43,25 @@ export class Cache<T> implements CacheInterface<T> {
3943
/**
4044
* Object that stores all cached entries.
4145
*/
42-
private cache: Record<string, CacheEntry<T>>;
46+
private cache: Map<string, CacheEntry<T>>;
4347

4448
/**
4549
* Creates an instance of Cache.
4650
* @param defaultValidityTime - The default validity time in milliseconds. Use 0 for unlimited cache duration.
51+
* @param maxSize - The maximum number of entries in the cache. Use Infinity for unlimited size. Items are evicted based on a least recently used (LRU) strategy.
4752
*/
48-
constructor(private defaultValidityTime: number) {
49-
this.cache = {};
53+
constructor(
54+
private defaultValidityTime: number,
55+
private maxSize = Infinity
56+
) {
57+
this.cache = new Map<string, CacheEntry<T>>();
5058
}
5159

5260
/**
5361
* Clear all cached items.
5462
*/
5563
clear(): void {
56-
this.cache = {};
64+
this.cache.clear();
5765
}
5866

5967
/**
@@ -62,7 +70,7 @@ export class Cache<T> implements CacheInterface<T> {
6270
* @returns A boolean value that indicates whether the entry exists in cache.
6371
*/
6472
hasKey(key: string): boolean {
65-
return this.cache.hasOwnProperty(key);
73+
return this.cache.has(key);
6674
}
6775

6876
/**
@@ -71,9 +79,19 @@ export class Cache<T> implements CacheInterface<T> {
7179
* @returns The corresponding entry to the provided key if it is still valid, returns `undefined` otherwise.
7280
*/
7381
get(key: string | undefined): T | undefined {
74-
return key && this.hasKey(key) && !isExpired(this.cache[key])
75-
? this.cache[key].entry
76-
: undefined;
82+
const entry = key ? this.cache.get(key) : undefined;
83+
if (entry) {
84+
if (isExpired(entry)) {
85+
this.cache.delete(key!);
86+
return undefined;
87+
}
88+
// LRU cache: Move accessed entry to the end of the Map to mark it as recently used
89+
if (this.maxSize !== Infinity) {
90+
this.cache.delete(key!);
91+
this.cache.set(key!, entry);
92+
}
93+
}
94+
return entry?.entry;
7795
}
7896

7997
/**
@@ -82,10 +100,41 @@ export class Cache<T> implements CacheInterface<T> {
82100
* @param item - The entry to cache.
83101
*/
84102
set(key: string | undefined, item: CacheEntry<T>): void {
85-
if (key) {
86-
const expires = item.expires ?? this.inferDefaultExpirationTime();
87-
this.cache[key] = { entry: item.entry, expires };
103+
if (!key) {
104+
return;
105+
}
106+
if (
107+
this.cache.size >= this.maxSize &&
108+
this.cache.size > 0 &&
109+
!this.cache.has(key)
110+
) {
111+
// Evict the least recently used (LRU) entry
112+
const lruKey = this.cache.keys().next().value;
113+
this.cache.delete(lruKey!); // SAFETY: size > 0
114+
}
115+
if (this.maxSize !== Infinity && this.cache.has(key)) {
116+
// If the key already exists, delete it to update its position in the LRU order
117+
this.cache.delete(key);
118+
}
119+
120+
const expires = item.expires ?? this.inferDefaultExpirationTime();
121+
this.cache.set(key, { entry: item.entry, expires });
122+
}
123+
124+
getOrInsertComputed(
125+
key: string | undefined,
126+
computeFn: () => CacheEntry<T>
127+
): T {
128+
if (!key) {
129+
return computeFn().entry;
130+
}
131+
const cachedEntry = this.get(key);
132+
if (cachedEntry !== undefined) {
133+
return cachedEntry;
88134
}
135+
const newEntry = computeFn();
136+
this.set(key, newEntry);
137+
return newEntry.entry;
89138
}
90139

91140
private inferDefaultExpirationTime(): number | undefined {

packages/http-client/src/http-client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,8 +455,8 @@ export function getAxiosConfigWithDefaultsWithoutMethod(): Omit<
455455
'method'
456456
> {
457457
return {
458-
httpAgent: new http.Agent(),
459-
httpsAgent: new https.Agent(),
458+
httpAgent: http.globalAgent,
459+
httpsAgent: https.globalAgent,
460460
timeout: 0, // zero means no timeout https://github.com/axios/axios/blob/main/README.md#request-config
461461
paramsSerializer: {
462462
serialize: (params = {}) =>

0 commit comments

Comments
 (0)