Skip to content

Commit c88d351

Browse files
committed
fix: Prevent fetch errors loops with invalid fetch implementations
1 parent 58b2ba1 commit c88d351

File tree

2 files changed

+91
-16
lines changed

2 files changed

+91
-16
lines changed

packages/browser/src/transports/fetch.ts

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,83 @@
11
import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core';
2-
import { Event, Response, SentryRequest, Session } from '@sentry/types';
3-
import { getGlobalObject, supportsReferrerPolicy, SyncPromise } from '@sentry/utils';
2+
import { Event, Response, SentryRequest, Session, TransportOptions } from '@sentry/types';
3+
import { getGlobalObject, logger, supportsReferrerPolicy, SyncPromise } from '@sentry/utils';
44

55
import { BaseTransport } from './base';
66

7-
const global = getGlobalObject<Window>();
7+
/**
8+
* A special usecase for incorrectly wrapped Fetch APIs in conjunction with ad-blockers.
9+
* Whenever someone wraps the Fetch API and returns the wrong promise chain,
10+
* this chain becomes orphaned and there is no possible way to capture it's rejections
11+
* other than allowing it bubble up to this very handler. eg.
12+
*
13+
* const f = window.fetch;
14+
* window.fetch = function () {
15+
* const p = f.apply(this, arguments);
16+
*
17+
* p.then(function() {
18+
* console.log('hi.');
19+
* });
20+
*
21+
* return p;
22+
* }
23+
*
24+
* `p.then(function () { ... })` is producing a completely separate promise chain,
25+
* however, what's returned is `p` - the result of original `fetch` call.
26+
*
27+
* This mean, that whenever we use the Fetch API to send our own requests, _and_
28+
* some ad-blocker blocks it, this orphaned chain will _always_ reject,
29+
* effectively causing another event to be captured.
30+
* This makes a whole process become an infinite loop, which we need to somehow
31+
* deal with, and break it in one way or another.
32+
*
33+
* To deal with this issue, we are making sure that we _always_ use the real
34+
* browser Fetch API, instead of relying on what `window.fetch` exposes.
35+
* The only downside to this would be missing our own requests as breadcrumbs,
36+
* but because we are already not doing this, it should be just fine.
37+
*
38+
* Possible failed fetch error messages per-browser:
39+
*
40+
* Chrome: Failed to fetch
41+
* Edge: Failed to Fetch
42+
* Firefox: NetworkError when attempting to fetch resource
43+
* Safari: resource blocked by content blocker
44+
*/
45+
46+
type FetchImpl = typeof fetch;
47+
48+
function getNativeFetchImplementation(): FetchImpl {
49+
// Make sure that the fetch we use is always the native one.
50+
const global = getGlobalObject<Window>();
51+
const document = global.document;
52+
// eslint-disable-next-line deprecation/deprecation
53+
if (typeof document?.createElement === `function`) {
54+
try {
55+
const sandbox = document.createElement('iframe');
56+
sandbox.hidden = true;
57+
document.head.appendChild(sandbox);
58+
if (sandbox.contentWindow?.fetch) {
59+
return sandbox.contentWindow.fetch.bind(global);
60+
}
61+
document.head.removeChild(sandbox);
62+
} catch (e) {
63+
logger.warn('Could not create sandbox iframe for pure fetch check, bailing to window.fetch: ', e);
64+
}
65+
}
66+
return global.fetch.bind(global);
67+
}
868

969
/** `fetch` based transport */
1070
export class FetchTransport extends BaseTransport {
71+
/**
72+
* Fetch API reference which always points to native browser implementation.
73+
*/
74+
private _fetch: typeof fetch;
75+
76+
constructor(options: TransportOptions, fetchImpl: FetchImpl = getNativeFetchImplementation()) {
77+
super(options);
78+
this._fetch = fetchImpl;
79+
}
80+
1181
/**
1282
* @inheritDoc
1383
*/
@@ -54,8 +124,7 @@ export class FetchTransport extends BaseTransport {
54124

55125
return this._buffer.add(
56126
new SyncPromise<Response>((resolve, reject) => {
57-
global
58-
.fetch(sentryRequest.url, options)
127+
this._fetch(sentryRequest.url, options)
59128
.then(response => {
60129
const headers = {
61130
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),

packages/browser/test/unit/transports/fetch.test.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ let transport: Transports.BaseTransport;
1919
describe('FetchTransport', () => {
2020
beforeEach(() => {
2121
fetch = (stub(window, 'fetch') as unknown) as SinonStub;
22-
transport = new Transports.FetchTransport({ dsn: testDsn });
22+
transport = new Transports.FetchTransport({ dsn: testDsn }, window.fetch);
2323
});
2424

2525
afterEach(() => {
@@ -83,12 +83,15 @@ describe('FetchTransport', () => {
8383
});
8484

8585
it('passes in headers', async () => {
86-
transport = new Transports.FetchTransport({
87-
dsn: testDsn,
88-
headers: {
89-
Authorization: 'Basic GVzdDp0ZXN0Cg==',
86+
transport = new Transports.FetchTransport(
87+
{
88+
dsn: testDsn,
89+
headers: {
90+
Authorization: 'Basic GVzdDp0ZXN0Cg==',
91+
},
9092
},
91-
});
93+
window.fetch,
94+
);
9295
const response = { status: 200, headers: new Headers() };
9396

9497
fetch.returns(Promise.resolve(response));
@@ -109,12 +112,15 @@ describe('FetchTransport', () => {
109112
});
110113

111114
it('passes in fetch parameters', async () => {
112-
transport = new Transports.FetchTransport({
113-
dsn: testDsn,
114-
fetchParameters: {
115-
credentials: 'include',
115+
transport = new Transports.FetchTransport(
116+
{
117+
dsn: testDsn,
118+
fetchParameters: {
119+
credentials: 'include',
120+
},
116121
},
117-
});
122+
window.fetch,
123+
);
118124
const response = { status: 200, headers: new Headers() };
119125

120126
fetch.returns(Promise.resolve(response));

0 commit comments

Comments
 (0)