Skip to content

Commit 5f4cceb

Browse files
fix: avoid cloning requests in FetchBuilder (#228)
1 parent 794cf8f commit 5f4cceb

File tree

10 files changed

+99
-172
lines changed

10 files changed

+99
-172
lines changed

.eslintrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"es/no-optional-chaining": "off",
2020
"es/no-nullish-coalescing-operators": "off",
2121
"no-inner-declarations": "off",
22-
"@typescript-eslint/no-use-before-define": "off"
22+
"@typescript-eslint/no-use-before-define": "off",
23+
"no-param-reassign": "off"
2324
}
2425
}

api/sdk.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export function pageViews(): Trackable.Manager;
157157

158158
// Warning: (ae-missing-release-tag) "SimpleFetch" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
159159
//
160-
// @public (undocumented)
160+
// @public
161161
export type SimpleFetch = (request: Request) => Promise<Response>;
162162

163163
// @public

packages/sdk/src/Confidence.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import { SdkId } from './generated/confidence/flags/resolver/v1/types';
1414
import { Trackable } from './Trackable';
1515
import { Closer } from './Closer';
1616
import { Subscribe, Observer, subject, changeObserver } from './observing';
17-
import { SimpleFetch, WaitUntil } from './types';
17+
import { WaitUntil } from './types';
1818
import { FlagResolution } from './FlagResolution';
1919
import { AccessiblePromise } from './AccessiblePromise';
2020
import { Telemetry } from './Telemetry';
21+
import { SimpleFetch } from './fetch-util';
2122

2223
/**
2324
* Confidence options, to be used for easier initialization of Confidence

packages/sdk/src/EventSenderEngine.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Value } from './Value';
22
import { Logger } from './logger';
3-
import { FetchBuilder, TimeUnit } from './fetch-util';
4-
import { SimpleFetch } from './types';
3+
import { FetchBuilder, InternalFetch, SimpleFetch, TimeUnit } from './fetch-util';
54
import { EventData } from './events';
65
interface Event {
76
eventDefinition: string;
@@ -36,7 +35,7 @@ export class EventSenderEngine {
3635
private readonly flushTimeoutMilliseconds: number;
3736
private readonly clientSecret: string;
3837
private readonly maxBatchSize: number;
39-
private readonly fetchImplementation: SimpleFetch;
38+
private readonly fetchImplementation: InternalFetch;
4039
private readonly publishUrl: string;
4140
private readonly logger: Logger;
4241
private pendingFlush: undefined | ReturnType<typeof setTimeout>;
@@ -70,12 +69,12 @@ export class EventSenderEngine {
7069
}
7170
this.fetchImplementation = fetchBuilder
7271
// update send-time before sending
73-
.modifyRequest(async request => {
74-
if (request.method === 'POST') {
75-
const body = JSON.stringify({ ...(await request.json()), sendTime: new Date().toISOString() });
76-
return new Request(request, { body });
72+
.modifyRequest(({ method, body }) => {
73+
if (method === 'POST' && body) {
74+
body = JSON.stringify({ ...JSON.parse(body), sendTime: new Date().toISOString() });
75+
return { body };
7776
}
78-
return request;
77+
return {};
7978
})
8079
.build(fetchImplementation);
8180
}
@@ -141,15 +140,15 @@ export class EventSenderEngine {
141140

142141
// Made public for unit testing
143142
public upload(batch: EventBatch): Promise<PublishError[]> {
144-
const request = new Request(this.publishUrl, {
143+
const request = {
145144
method: 'POST',
146145
headers: { 'Content-Type': 'application/json' },
147146
body: JSON.stringify({
148147
...batch,
149148
events: batch.events.map(e => ({ ...e, eventDefinition: `eventDefinitions/${e.eventDefinition}` })),
150149
}),
151-
});
152-
return this.fetchImplementation(request)
150+
};
151+
return this.fetchImplementation(this.publishUrl, request)
153152
.then(resp => resp.json())
154153
.then(({ errors }) => errors);
155154
}

packages/sdk/src/FlagResolverClient.test.ts

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from './FlagResolverClient';
99
import { setMaxListeners } from 'node:events';
1010
import { SdkId } from './generated/confidence/flags/resolver/v1/types';
11-
import { abortableSleep, FetchBuilder } from './fetch-util';
11+
import { abortableSleep, FetchBuilder, InternalFetch, SimpleFetch } from './fetch-util';
1212
import { ApplyFlagsRequest, ResolveFlagsRequest } from './generated/confidence/flags/resolver/v1/api';
1313
import { FailedFlagResolution, FlagResolution } from './FlagResolution';
1414
import { Telemetry } from './Telemetry';
@@ -24,7 +24,7 @@ const dummyResolveToken = Uint8Array.from(atob('SGVsbG9Xb3JsZA=='), c => c.charC
2424
const resolveHandlerMock = jest.fn();
2525
const applyHandlerMock = jest.fn();
2626

27-
const fetchImplementation = async (request: Request): Promise<Response> => {
27+
const fetchImplementation: SimpleFetch = async (request): Promise<Response> => {
2828
await abortableSleep(0, request.signal);
2929

3030
let handler: (reqBody: any) => any;
@@ -327,14 +327,15 @@ describe('Backend environment Evaluation', () => {
327327
});
328328

329329
describe('intercept', () => {
330-
const fetchMock = jest.fn<Promise<Response>, [Request]>();
331-
const fetchBuilder = new FetchBuilder();
332-
let underTest: typeof fetch;
330+
const fetchMock: jest.MockedFn<SimpleFetch> = jest.fn();
331+
let underTest: InternalFetch;
333332

334333
beforeEach(() => {
335334
jest.useFakeTimers();
336335
jest.setSystemTime(0);
337-
underTest = withRequestLogic(fetchMock, console);
336+
const fetchBuilder = new FetchBuilder();
337+
withRequestLogic(fetchBuilder, console);
338+
underTest = fetchBuilder.build(fetchMock);
338339
});
339340
afterEach(() => {
340341
if (jest.getTimerCount() !== 0) throw new Error('test finished with remaining timers');
@@ -347,7 +348,9 @@ describe('intercept', () => {
347348
);
348349

349350
beforeEach(() => {
350-
underTest = withTelemetryData(fetchBuilder, telemetryMock).build(fetchMock);
351+
const fetchBuilder = new FetchBuilder();
352+
withTelemetryData(fetchBuilder, telemetryMock);
353+
underTest = fetchBuilder.build(fetchMock);
351354
telemetryMock.getSnapshot = jest.fn().mockReturnValue({
352355
libraryTraces: [
353356
{
@@ -364,19 +367,8 @@ describe('intercept', () => {
364367
fetchMock.mockResolvedValue(new Response());
365368
await underTest('https://resolver.confidence.dev/v1/flags:resolve', { method: 'POST', body: '{}' });
366369
expect(fetchMock).toBeCalledTimes(1);
367-
const request = fetchMock.mock.calls[0][0];
368-
expect(request.headers.has('X-Confidence-Telemetry')).toBeTruthy();
369-
expect(request.headers.get('X-Confidence-Telemetry')).toEqual('CgwIAxIEdGVzdBoCCAMQBA==');
370-
});
371-
});
372-
373-
describe('withRequestLogic', () => {
374-
it('should throw on unknown urls', async () => {
375-
fetchMock.mockResolvedValue(new Response());
376-
377-
await expect(underTest('https://resolver.confidence.dev/v1/flags:bad')).rejects.toThrow(
378-
'Unexpected url: https://resolver.confidence.dev/v1/flags:bad',
379-
);
370+
const headers = fetchMock.mock.calls[0][0].headers;
371+
expect(headers.get('X-CONFIDENCE-TELEMETRY')).toEqual('CgwIAxIEdGVzdBoCCAMQBA==');
380372
});
381373
});
382374

packages/sdk/src/FlagResolverClient.ts

Lines changed: 42 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Applier, FlagResolution } from './FlagResolution';
44
import { Telemetry, Meter } from './Telemetry';
55
import { Value } from './Value';
66
import { Context } from './context';
7-
import { FetchBuilder, TimeUnit } from './fetch-util';
7+
import { FetchBuilder, InternalFetch, SimpleFetch, TimeUnit } from './fetch-util';
88
import {
99
ResolveFlagsRequest,
1010
ResolveFlagsResponse,
@@ -18,7 +18,7 @@ import {
1818
Monitoring,
1919
} from './generated/confidence/telemetry/v1/telemetry';
2020
import { Logger } from './logger';
21-
import { SimpleFetch, WaitUntil } from './types';
21+
import { WaitUntil } from './types';
2222

2323
const FLAG_PREFIX = 'flags/';
2424
const retryCodes = new Set([408, 502, 503, 504]);
@@ -99,7 +99,7 @@ export type FlagResolverClientOptions = {
9999
};
100100

101101
export class FetchingFlagResolverClient implements FlagResolverClient {
102-
private readonly fetchImplementation: SimpleFetch;
102+
private readonly fetchImplementation: InternalFetch;
103103
private readonly clientSecret: string;
104104
private readonly sdk: Sdk;
105105
private readonly applyDebounce: number;
@@ -127,11 +127,14 @@ export class FetchingFlagResolverClient implements FlagResolverClient {
127127
version: sdk.version,
128128
id: LibraryTraces_TraceId.TRACE_ID_RESOLVE_LATENCY,
129129
});
130-
const fetchBuilderWithTelemetry = withTelemetryData(new FetchBuilder(), telemetry);
131-
// TODO think about both resolve and apply request logic for backends
132-
const fetchWithTelemetry = fetchBuilderWithTelemetry.build(fetchImplementation);
133-
this.fetchImplementation =
134-
environment === 'backend' ? fetchWithTelemetry : withRequestLogic(fetchWithTelemetry, logger);
130+
131+
const fetchBuilder = new FetchBuilder();
132+
withTelemetryData(fetchBuilder, telemetry);
133+
if (environment === 'client') {
134+
withRequestLogic(fetchBuilder, logger);
135+
}
136+
137+
this.fetchImplementation = fetchBuilder.build(fetchImplementation);
135138

136139
this.clientSecret = clientSecret;
137140
this.sdk = sdk;
@@ -214,31 +217,27 @@ export class FetchingFlagResolverClient implements FlagResolverClient {
214217
}
215218

216219
async apply(request: ApplyFlagsRequest): Promise<void> {
217-
const resp = await this.fetchImplementation(
218-
new Request(`${this.baseUrl}/flags:apply`, {
219-
method: 'POST',
220-
headers: {
221-
'Content-Type': 'application/json',
222-
},
223-
body: JSON.stringify(ApplyFlagsRequest.toJSON(request)),
224-
}),
225-
);
220+
const resp = await this.fetchImplementation(`${this.baseUrl}/flags:apply`, {
221+
method: 'POST',
222+
headers: {
223+
'Content-Type': 'application/json',
224+
},
225+
body: JSON.stringify(ApplyFlagsRequest.toJSON(request)),
226+
});
226227
if (!resp.ok) {
227228
throw new Error(`${resp.status}: ${resp.statusText}`);
228229
}
229230
}
230231

231232
async resolveFlagsJson(request: ResolveFlagsRequest, signal: AbortSignal): Promise<ResolveFlagsResponse> {
232-
const resp = await this.fetchImplementation(
233-
new Request(`${this.baseUrl}/flags:resolve`, {
234-
method: 'POST',
235-
headers: {
236-
'Content-Type': 'application/json',
237-
},
238-
body: JSON.stringify(ResolveFlagsRequest.toJSON(request)),
239-
signal,
240-
}),
241-
);
233+
const resp = await this.fetchImplementation(`${this.baseUrl}/flags:resolve`, {
234+
method: 'POST',
235+
headers: {
236+
'Content-Type': 'application/json',
237+
},
238+
body: JSON.stringify(ResolveFlagsRequest.toJSON(request)),
239+
signal,
240+
});
242241
if (!resp.ok) {
243242
throw new Error(`${resp.status}: ${resp.statusText}`);
244243
}
@@ -315,24 +314,19 @@ export class CachingFlagResolverClient implements FlagResolverClient {
315314
}
316315
}
317316

318-
export function withTelemetryData(fetchBuilder: FetchBuilder, telemetry: Telemetry): FetchBuilder {
319-
return fetchBuilder.modifyRequest(async request => {
317+
export function withTelemetryData(fetchBuilder: FetchBuilder, telemetry: Telemetry) {
318+
fetchBuilder.modifyRequest(({ headers }) => {
320319
const monitoring = telemetry.getSnapshot();
321320
if (monitoring.libraryTraces.length > 0) {
322-
const headers = new Headers(request.headers);
323321
const base64Message = btoa(String.fromCharCode(...Monitoring.encode(monitoring).finish()));
324322

325-
headers.set('X-CONFIDENCE-TELEMETRY', base64Message);
326-
return new Request(request, { headers });
323+
return { headers: { ...headers, ['X-CONFIDENCE-TELEMETRY']: base64Message } };
327324
}
328-
return request;
325+
return {};
329326
});
330327
}
331328

332-
export function withRequestLogic(
333-
fetchImplementation: (request: Request) => Promise<Response>,
334-
logger: Logger,
335-
): typeof fetch {
329+
export function withRequestLogic(fetchBuilder: FetchBuilder, logger: Logger) {
336330
const fetchResolve = new FetchBuilder()
337331
// infinite retries without delay until aborted by timeout
338332
.compose(next => async request => {
@@ -347,8 +341,7 @@ export function withRequestLogic(
347341
.rejectNotOk()
348342
.retry()
349343
.rejectOn(response => retryCodes.has(response.status))
350-
.rateLimit(1, { initialTokens: 3, maxTokens: 2 })
351-
.build(fetchImplementation);
344+
.rateLimit(1, { initialTokens: 3, maxTokens: 2 });
352345

353346
const fetchApply = new FetchBuilder()
354347
.limitPending(1000)
@@ -358,22 +351,17 @@ export function withRequestLogic(
358351
.rejectOn(response => retryCodes.has(response.status))
359352
.rateLimit(2)
360353
// update send-time before sending
361-
.modifyRequest(async request => {
362-
if (request.method === 'POST') {
363-
const body = JSON.stringify({ ...(await request.clone().json()), sendTime: new Date().toISOString() });
364-
return new Request(request, { body });
354+
.modifyRequest(({ method, body }) => {
355+
if (method === 'POST' && body) {
356+
body = JSON.stringify({ ...JSON.parse(body), sendTime: new Date().toISOString() });
357+
return { body };
365358
}
366-
return request;
367-
})
368-
.build(fetchImplementation);
369-
370-
return (
371-
new FetchBuilder()
372-
.route(url => url.endsWith('flags:resolve'), fetchResolve)
373-
.route(url => url.endsWith('flags:apply'), fetchApply)
374-
// throw so we notice changes in endpoints that should be handled here
375-
.build(request => Promise.reject(new Error(`Unexpected url: ${request.url}`)))
376-
);
359+
return {};
360+
});
361+
362+
fetchBuilder
363+
.route(url => url.endsWith('flags:resolve'), fetchResolve)
364+
.route(url => url.endsWith('flags:apply'), fetchApply);
377365
}
378366

379367
function withTimeout(signal: AbortSignal, timeout: number, reason?: any): AbortSignal {

packages/sdk/src/fetch-util.test.ts

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,6 @@ import { FetchBuilder } from './fetch-util';
33
jest.useFakeTimers();
44

55
describe('FetchBuilder', () => {
6-
describe('abortPrevious', () => {
7-
const response = new Response(null, { status: 200 });
8-
const simpleFetchMock = jest.fn<Promise<Response>, [Request]>(
9-
({ signal }) =>
10-
new Promise((resolve, reject) => {
11-
signal.onabort = () => reject(signal.reason);
12-
setTimeout(() => resolve(response), 100);
13-
}),
14-
);
15-
it('should pass one request through', async () => {
16-
const underTest = new FetchBuilder().abortPrevious().build(simpleFetchMock);
17-
18-
const result = underTest('http://www.spotify.com');
19-
jest.runAllTimers();
20-
21-
expect(await result).toBe(response);
22-
});
23-
24-
it('should abort first request when a second arrives', async () => {
25-
const underTest = new FetchBuilder().abortPrevious().build(simpleFetchMock);
26-
27-
// we need to turn the catch into a result or node will complain about unh
28-
expect(underTest('http://www.spotify.com')).rejects.toThrow('Request superseded');
29-
expect(underTest('http://www.spotify.com')).resolves.toBe(response);
30-
31-
await jest.runAllTimersAsync();
32-
33-
expect.assertions(2);
34-
});
35-
});
366
describe('rateLimit', () => {
377
it('it keeps request rate under maxRate', async () => {
388
let requestCount = 0;
@@ -213,7 +183,7 @@ describe('FetchBuilder', () => {
213183
const simpleFetchMock = jest.fn();
214184
simpleFetchMock.mockResolvedValue(new Response());
215185
const requestModifyingFetch = new FetchBuilder()
216-
.modifyRequest(async request => new Request(`${request.url}modified`, request))
186+
.modifyRequest(async ({ url }) => ({ url: `${url}modified` }))
217187
.build(simpleFetchMock);
218188

219189
await requestModifyingFetch('http://test.com');

0 commit comments

Comments
 (0)