Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(fetch): properly release fetch during long-lived stream handling #13967

Open
wants to merge 45 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
ed91aaf
fix(fetch): ensure proper cancellation of child streams when parent s…
Lei-k Oct 12, 2024
f878695
style(fetch): fix formatting
Lei-k Oct 12, 2024
d0282f4
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 14, 2024
2c6d34e
fix: resolve multiple ESLint issues
Lei-k Oct 14, 2024
4382ce0
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 14, 2024
c7c943f
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 15, 2024
643a342
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 15, 2024
f3aaa5d
Immediate stream cancellation after timeout in `_tryGetResponseText`
Lei-k Oct 15, 2024
7f92ea3
Merge branch 'fix/fetch-not-release' of https://github.com/Lei-k/sent…
Lei-k Oct 15, 2024
9b83669
fix conflicts
Lei-k Oct 15, 2024
1d29564
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 16, 2024
80aa60d
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 16, 2024
0cf8645
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 18, 2024
6d66814
fix file formatting
Lei-k Oct 18, 2024
0d10106
Update test cases to handle new logic in fetchUtils
Lei-k Oct 18, 2024
536cc02
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 18, 2024
78ac956
feat: define whatwg's stream types
Lei-k Oct 19, 2024
7fd0ffe
fix type error for tests
Lei-k Oct 19, 2024
78b3ed3
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 23, 2024
8c99aee
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 27, 2024
5ec50f6
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 29, 2024
3923ecb
Merge branch 'getsentry:develop' into fix/fetch-not-release
Lei-k Oct 29, 2024
f13e8c2
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 30, 2024
4be816e
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 30, 2024
207ee69
Merge branch 'develop' into fix/fetch-not-release
Lei-k Oct 31, 2024
94c0e2e
Merge branch 'develop' into fix/fetch-not-release
Lei-k Nov 2, 2024
3ba1c20
Merge branch 'develop' into fix/fetch-not-release
Lei-k Nov 4, 2024
189848d
Merge branch 'develop' into fix/fetch-not-release
Lei-k Nov 8, 2024
8139565
Merge branch 'develop' into fix/fetch-not-release
Lei-k Nov 15, 2024
8780cda
Merge branch 'develop' into fix/fetch-not-release
Lei-k Nov 18, 2024
9ce2d8e
ref: Resolve merge conflict between develop and fix/fetch-not-release
Lei-k Dec 17, 2024
5c6ce1d
ref: fix typo
Lei-k Dec 17, 2024
2ed739d
ref: prettify file format
Lei-k Dec 17, 2024
02c2448
ref: fix file format
Lei-k Dec 17, 2024
b0b096a
Merge branch 'develop' into fix/fetch-not-release
Lei-k Dec 17, 2024
8181799
Merge branch 'develop' into fix/fetch-not-release
Lei-k Dec 17, 2024
dc69ad3
Merge branch 'develop' into fix/fetch-not-release
Lei-k Dec 17, 2024
dd4505e
chore: resolve conflict from PR #14745
Lei-k Dec 18, 2024
e506037
Merge branch 'develop' into fix/fetch-not-release
Lei-k Dec 18, 2024
423bd67
Merge branch 'develop' into fix/fetch-not-release
Lei-k Dec 19, 2024
7f0c21b
Merge branch 'develop' into fix/fetch-not-release
Lei-k Dec 19, 2024
78d4453
Merge branch 'develop' into fix/fetch-not-release
Lei-k Dec 23, 2024
8137807
Merge branch 'develop' into fix/fetch-not-release
Lei-k Dec 23, 2024
617fcc3
Merge branch 'develop' into fix/fetch-not-release
Lei-k Dec 25, 2024
09a0fda
Merge branch 'develop' into fix/fetch-not-release
Lei-k Dec 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 35 additions & 16 deletions packages/replay-internal/src/coreHandlers/util/fetchUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,21 +293,40 @@ function _tryCloneResponse(response: Response): Response | void {
* Fetch can return a streaming body, that may not resolve (or not for a long time).
* If that happens, we rather abort after a short time than keep waiting for this.
*/
function _tryGetResponseText(response: Response): Promise<string | undefined> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timeout while trying to read response body')), 500);

_getResponseText(response)
.then(
txt => resolve(txt),
reason => reject(reason),
)
.finally(() => clearTimeout(timeout));
});
}
async function _tryGetResponseText(response: Response): Promise<string | undefined> {
if (!response.body) {
throw new Error('Response has no body');
}

const reader = response.body.getReader();

const decoder = new TextDecoder();
let result = '';
let running = true;

const timeout = setTimeout(() => {
if (running) {
reader.cancel('Timeout while trying to read response body').catch(_ => {
// This block is only triggered when stream has already been released,
// so it's safe to ignore.
});
}
}, 500);

async function _getResponseText(response: Response): Promise<string> {
// Force this to be a promise, just to be safe
// eslint-disable-next-line no-return-await
return await response.text();
try {
while (running) {
const { value, done } = await reader.read();

running = !done;

if (value) {
result += decoder.decode(value, { stream: !done });
}
}
} finally {
clearTimeout(timeout);
reader.releaseLock();
}

return result;
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ function getMockResponse(contentLength?: string, body?: string, headers?: Record
...headers,
};

const encoder = new TextEncoder();

const response = {
headers: {
has: (prop: string) => {
Expand All @@ -49,6 +51,24 @@ function getMockResponse(contentLength?: string, body?: string, headers?: Record
return internalHeaders[prop.toLowerCase() ?? ''];
},
},
body: {
getReader: () => {
return {
read: () => {
return Promise.resolve({
done: true,
value: encoder.encode(body),
});
},
cancel: async () => {
// noop
},
releaseLock: async () => {
// noop
},
};
},
},
clone: () => response,
text: () => Promise.resolve(body),
} as unknown as Response;
Expand Down Expand Up @@ -741,6 +761,7 @@ other-header: test`;
options.networkCaptureBodies = true;

const largeBody = JSON.stringify({ a: LARGE_BODY });
const encoder = new TextEncoder();

const breadcrumb: Breadcrumb = {
category: 'fetch',
Expand All @@ -756,6 +777,24 @@ other-header: test`;
get: () => '',
},
clone: () => mockResponse,
body: {
getReader: () => {
return {
read: () => {
return Promise.resolve({
done: true,
value: encoder.encode(largeBody),
});
},
cancel: async () => {
// noop
},
releaseLock: async () => {
// noop
},
};
},
},
text: () => Promise.resolve(largeBody),
} as unknown as Response;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ describe('Unit | coreHandlers | util | fetchUtils', () => {
});

it('works with text body', async () => {
const encoder = new TextEncoder();

const mockRead = vi
.fn()
.mockResolvedValueOnce({
value: encoder.encode('text body'),
done: false,
})
.mockResolvedValueOnce({
value: null,
done: true,
});

const response = {
headers: {
has: () => {
Expand All @@ -52,6 +65,19 @@ describe('Unit | coreHandlers | util | fetchUtils', () => {
return undefined;
},
},
body: {
getReader: () => {
return {
read: mockRead,
cancel: async (reason?: any) => {
mockRead.mockRejectedValue(new Error(reason));
},
releaseLock: async () => {
// noop
},
};
},
},
clone: () => response,
text: () => Promise.resolve('text body'),
} as unknown as Response;
Expand All @@ -74,6 +100,8 @@ describe('Unit | coreHandlers | util | fetchUtils', () => {
});

it('works with body that fails', async () => {
const mockRead = vi.fn().mockRejectedValueOnce(new Error('cannot read'));

const response = {
headers: {
has: () => {
Expand All @@ -83,6 +111,19 @@ describe('Unit | coreHandlers | util | fetchUtils', () => {
return undefined;
},
},
body: {
getReader: () => {
return {
read: mockRead,
cancel: async (_?: any) => {
// noop
},
releaseLock: async () => {
// noop
},
};
},
},
clone: () => response,
text: () => Promise.reject('cannot read'),
} as unknown as Response;
Expand All @@ -105,6 +146,12 @@ describe('Unit | coreHandlers | util | fetchUtils', () => {
});

it('works with body that times out', async () => {
const encoder = new TextEncoder();
const mockRead = vi.fn();

let cancelled = false;
let cancellReason = '';

const response = {
headers: {
has: () => {
Expand All @@ -114,6 +161,38 @@ describe('Unit | coreHandlers | util | fetchUtils', () => {
return undefined;
},
},
body: {
getReader: () => {
return {
read: async () => {
if (cancelled) {
mockRead.mockRejectedValue(new Error(cancellReason));
} else {
mockRead.mockResolvedValueOnce({
value: encoder.encode('chunk'),
done: false,
});
}

await new Promise(res => {
setTimeout(() => {
res(1);
}, 200);
});

// eslint-disable-next-line no-return-await
return await mockRead();
},
cancel: async (reason?: any) => {
cancelled = true;
cancellReason = reason;
},
releaseLock: async () => {
// noop
},
};
},
},
clone: () => response,
text: () => new Promise(resolve => setTimeout(() => resolve('text body'), 1000)),
} as unknown as Response;
Expand Down
22 changes: 10 additions & 12 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,7 @@ export type { StackFrame } from './stackframe';
export type { Stacktrace, StackParser, StackLineParser, StackLineParserFn } from './stacktrace';
export type { PropagationContext, TracePropagationTargets, SerializedTraceData } from './tracing';
export type { StartSpanOptions } from './startSpanOptions';
export type {
TraceparentData,
TransactionSource,
} from './transaction';
export type { TraceparentData, TransactionSource } from './transaction';
export type { CustomSamplingContext, SamplingContext } from './samplingcontext';
export type {
DurationUnit,
Expand All @@ -146,7 +143,14 @@ export type {
TransportRequestExecutor,
} from './transport';
export type { User } from './user';
export type { WebFetchHeaders, WebFetchRequest } from './webfetchapi';
export type {
WebFetchHeaders,
WebFetchRequest,
WebFetchResponse,
WebReadableStream,
WebReadableStreamDefaultReader,
WebReadableStreamReadResult,
} from './whatwg';
export type { WrappedFunction } from './wrappedfunction';
export type {
HandlerDataFetch,
Expand All @@ -163,13 +167,7 @@ export type {

export type { BrowserClientReplayOptions, BrowserClientProfilingOptions } from './browseroptions';
export type { CheckIn, MonitorConfig, FinishedCheckIn, InProgressCheckIn, SerializedCheckIn } from './checkin';
export type {
MetricsAggregator,
MetricBucketItem,
MetricInstance,
MetricData,
Metrics,
} from './metrics';
export type { MetricsAggregator, MetricBucketItem, MetricInstance, MetricData, Metrics } from './metrics';
export type { ParameterizedString } from './parameterize';
export type { ContinuousProfiler, ProfilingIntegration, Profiler } from './profiling';
export type { ViewHierarchyData, ViewHierarchyWindow } from './view-hierarchy';
10 changes: 2 additions & 8 deletions packages/types/src/instrument.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// This should be: null | Blob | BufferSource | FormData | URLSearchParams | string
// But since not all of those are available in node, we just export `unknown` here for now

import type { WebFetchHeaders } from './webfetchapi';
import type { WebFetchResponse } from './whatwg';

// Make sure to cast it where needed!
type XHRSendInput = unknown;
Expand Down Expand Up @@ -48,13 +48,7 @@ export interface HandlerDataFetch {
fetchData: SentryFetchData; // This data is among other things dumped directly onto the fetch breadcrumb data
startTimestamp: number;
endTimestamp?: number;
// This is actually `Response` - Note: this type is not complete. Add to it if necessary.
response?: {
readonly ok: boolean;
readonly status: number;
readonly url: string;
headers: WebFetchHeaders;
};
response?: WebFetchResponse;
error?: unknown;
}

Expand Down
17 changes: 0 additions & 17 deletions packages/types/src/webfetchapi.ts

This file was deleted.

38 changes: 38 additions & 0 deletions packages/types/src/whatwg/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// These are vendored types for the standard web fetch API types because typescript needs the DOM types to be able to understand the `Request`, `Headers`, ... types and not everybody has those.

import type { WebReadableStream } from './stream';

export interface WebFetchHeaders {
append(name: string, value: string): void;
delete(name: string): void;
get(name: string): string | null;
has(name: string): boolean;
set(name: string, value: string): void;
forEach(callbackfn: (value: string, key: string, parent: WebFetchHeaders) => void): void;
}

export interface WebFetchRequest {
readonly headers: WebFetchHeaders;
readonly method: string;
readonly url: string;
clone(): WebFetchRequest;
}

export interface WebFetchResponse {
readonly ok: boolean;
readonly status: number;
readonly statusText: string;
readonly headers: WebFetchHeaders;
readonly url: string;
readonly redirected: boolean;
readonly body: WebReadableStream | null;

clone(): WebFetchResponse;

// Methods to consume the response body
json(): Promise<any>; // Parses response as JSON
text(): Promise<string>; // Reads response body as text
arrayBuffer(): Promise<ArrayBuffer>; // Reads response body as ArrayBuffer
blob(): Promise<object>; // Reads response body as Blob
formData(): Promise<object>; // Reads response body as FormData
}
3 changes: 3 additions & 0 deletions packages/types/src/whatwg/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type { WebReadableStream, WebReadableStreamDefaultReader, WebReadableStreamReadResult } from './stream';

export type { WebFetchHeaders, WebFetchRequest, WebFetchResponse } from './fetch';
23 changes: 23 additions & 0 deletions packages/types/src/whatwg/stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export interface WebReadableStream<R = any> {
locked: boolean; // Indicates if the stream is currently locked

cancel(reason?: any): Promise<void>; // Cancels the stream with an optional reason
getReader(): WebReadableStreamDefaultReader<R>; // Returns a reader for the stream
}

export interface WebReadableStreamDefaultReader<R = any> {
closed: boolean;
// Closes the stream and resolves the reader's lock
cancel(reason?: any): Promise<void>;

// Returns a promise with the next chunk in the stream
read(): Promise<WebReadableStreamReadResult<R>>;

// Releases the reader's lock on the stream
releaseLock(): void;
}

export interface WebReadableStreamReadResult<R = any> {
done: boolean; // True if the reader is done with the stream
value?: R; // The data chunk read from the stream (if not done)
}
Loading