The problem is that our core api & fetch helper is tightly coupled to some UI elements that will open depending on specific error codes that are returned from the server. Some handlers are configurable, and not included in _admin, not this is not consistently implemented. There are error handlers are multiple layers which makes refactoring in pieces tedious and risky.
Ideally the core fetch helper would only directly deal with preparing the target url for the request, and deserializing the response, allowing handlers for each situation to be injected for different scenarios. Currently we have the 'App' scenario and '_admin' with different sets of handlers. In future I'd also like to implement a ServiceWorker scenario where error handlers are de-coupled from the UI completely.
Read blow for an AI assisted reference of hose api.tsx works.
Followed by another document which compares the new sentryCellFetch with api.requestPromise()
static/app/api.tsx — Complete API Reference
Sentry's core HTTP client. Used throughout the frontend to communicate with the backend REST API.
This document also covers the Jest mock at static/app/__mocks__/api.tsx.
Module-level: apiNavigate + setApiNavigate (lines 29–33)
apiNavigate — module-private ReactRouter3Navigate | null, initially null. Holds a React Router navigate function so this non-React module can do client-side redirects.
setApiNavigate(navigate) — exported setter. Called once at app bootstrap. All usages are guarded with ?., so if never set, redirects silently no-op.
Edge case: calling it twice silently overwrites.
Request class (lines 35–61)
| Property |
Type |
Description |
alive |
boolean |
true until .cancel() is called |
requestPromise |
Promise<Response> |
The underlying fetch promise |
aborter |
AbortController? |
Undefined if browser doesn't support it or skipAbort: true |
constructor(requestPromise, aborter?) — stores both, sets alive = true.
cancel() — sets alive = false, calls aborter?.abort(), emits metric('app.api.request-abort', 1). If the request already settled, the abort is a no-op.
ApiResult<Data> type (lines 63–67)
[data: Data, statusText: string | undefined, resp: ResponseMeta | undefined]
The "full" resolution type when requestPromise is called with includeAllArgs: true.
ResponseMeta<R> type (lines 69–90)
| Property |
Type |
status |
Response['status'] |
statusText |
Response['statusText'] |
responseJSON |
R |
responseText |
string |
getResponseHeader |
(header: string) => string | null |
Pure data shape wrapping the response for the jQuery-compat callback API.
csrfSafeMethod(method?) — internal (lines 95–98)
Returns true for GET|HEAD|OPTIONS|TRACE. Used to decide whether to attach X-CSRFToken. Pure function; undefined input returns false.
isSimilarOrigin(target, origin) — exported (lines 105–126)
Checks if two URLs share an ancestor domain (parent-child or sibling subdomains).
Behavior:
- Parses both with
new URL. Relative target is resolved against origin.
- Returns
true if either hostname .endsWith() the other (parent-child or exact match).
- Otherwise strips one subdomain level from each and compares the remainder (sibling check). Returns
false if either has < 2 segments after stripping.
Edge cases: Relative paths always return true. Bare localhost returns false in the sibling check. new URL() throws if origin is invalid.
ALLOWED_ANON_PAGES — internal (lines 129–135)
Array of RegExp for paths that don't trigger auth redirects on 401: /accept/, /share/, /auth/login/, /join-request/, /unsubscribe/.
globalErrorHandlers — internal (lines 140–142)
Array<(resp: ResponseMeta, options: RequestOptions) => boolean>;
Chain-of-responsibility registry. Handlers return true to suppress the per-request error callback. Populated by initApiClientErrorHandling.
initApiClientErrorHandling() — exported (lines 144–196)
Pushes a 401 handler into globalErrorHandlers. Should be called exactly once at bootstrap (no duplicate guard).
The handler's logic on every non-2xx response:
- Skip if
resp.status !== 401 or page is in ALLOWED_ANON_PAGES.
- Skip if
options.allowAuthError is true.
- Skip if
code is sudo-required, ignore, 2fa-required, or app-connect-authentication-error.
sso-required → window.location.assign(extra.loginUrl). Returns true.
member-disabled-over-limit → apiNavigate?.(extra.next, {replace: true}). Returns true.
- Otherwise: sets
session_expired cookie (unless demo mode), then either navigates to /auth/login/ (SPA) or window.location.reload(). Returns true.
Returns true = skip per-request error callback. Returns false = let it through.
Side effects: May set session_expired cookie, hard-redirect the browser, or trigger SPA navigation.
Edge cases:
- Called multiple times → duplicate handlers accumulate.
apiNavigate not set → SPA navigation silently no-ops.
extra.loginUrl or extra.next accessed without null-checking → TypeError if extra is undefined.
buildRequestUrl(baseUrl, path, options) — internal (lines 201–226)
- Serializes
options.query via qs.stringify. On failure, captures to Sentry and re-throws.
- Prepends
baseUrl if path doesn't already contain it.
- Calls
resolveHostname(fullUrl, options.host) for multi-region routing.
- Appends query string with
? or & as needed.
hasProjectBeenRenamed(response) — exported (lines 234–249)
Checks response.responseJSON.detail.code === PROJECT_MOVED. If so, calls redirectToProject(slug) and returns true. Otherwise returns false.
Historical note: this may never fire in practice because browsers auto-follow 302 redirects.
RequestCallbacks type (lines 252–267)
| Callback |
Signature |
success? |
(data: any, textStatus?: string, resp?: ResponseMeta) => void |
error? |
(...args: any[]) => void (loosely typed) |
complete? |
(resp: ResponseMeta, textStatus: string) => void |
RequestOptions type (lines 269–308)
Extends RequestCallbacks with:
| Property |
Type |
Default |
Description |
allowAuthError |
boolean |
false |
Opt out of global 401 redirect handling |
data |
any |
— |
Body payload. JSON-stringified for non-GET, non-FormData |
headers |
Record<string, string> |
— |
Extra headers merged over client defaults |
host |
string |
— |
Hostname override for hybrid-cloud routing |
method |
'DELETE' | 'GET' | 'POST' | 'PUT' |
— |
HTTP verb |
preservedError |
Error |
— |
Pre-constructed error for stack trace coalescence |
query |
Record<string, any> |
— |
Query parameters serialized onto the URL |
skipAbort |
boolean |
false |
Exclude from bulk cancellation via client.clear() |
ClientOptions type — internal (lines 310–323)
| Property |
Type |
Default (in constructor) |
baseUrl |
string |
'/api/0' |
credentials |
RequestCredentials |
'include' |
headers |
HeadersInit |
Client.JSON_HEADERS |
HandleRequestErrorOptions type — internal (lines 325–329)
| Property |
Type |
Description |
id |
string |
Unique request ID |
path |
string |
Original API path |
requestOptions |
Readonly<RequestOptions> |
Original options for potential retry |
Client class (lines 336–723)
Static: Client.JSON_HEADERS (lines 342–345)
{ Accept: 'application/json; charset=utf-8', 'Content-Type': 'application/json' }
Constructor (options: ClientOptions = {}) (lines 347–352)
| Property |
Default |
baseUrl |
'/api/0' |
headers |
Client.JSON_HEADERS |
credentials |
'include' |
activeRequests |
{} |
wrapCallback<T>(id, func, cleanup = false) (lines 354–379)
Returns a closure that:
- Looks up
activeRequests[id].
- If
cleanup = true, deletes the entry from activeRequests.
- If
req is missing or alive === false, returns early (callback suppressed).
- If
hasProjectBeenRenamed(...args) returns true, returns early (redirect handled).
- Calls
func?.apply(req, args).
Edge cases:
func = undefined → func?.apply() is a no-op.
- Called twice with
cleanup = true → second call finds req = undefined, returns early. Idempotent.
@ts-expect-error on line 372 suppresses a tuple-spread type error for hasProjectBeenRenamed.
clear() (lines 384–387)
Calls .cancel() on every Request in activeRequests. Does not remove entries (they remain as dead references; the complete handler cleans up).
Side effects: Sets alive = false on all requests, sends abort signals, emits app.api.request-abort per request.
handleRequestError({id, path, requestOptions}, response, textStatus, errorThrown) (lines 389–426)
- Reads
response.responseJSON.detail.code.
- Sudo/superuser flow (
SUDO_REQUIRED or SUPERUSER_REQUIRED):
- Opens sudo modal via
openSudo().
- Modal's
retryRequest: re-issues request via this.requestPromise(). On success calls options.success, on failure calls options.error.
- Modal's
onClose: calls options.error(response) if retry didn't succeed.
- Returns early — per-request error callback is not called via
wrapCallback.
- Normal error flow: Wraps
options.error via wrapCallback (no cleanup) and calls it with (response, textStatus, errorThrown).
Edge cases:
- In the sudo retry path, callbacks bypass
wrapCallback guards (no alive-check or project-rename check).
- Timing issue: if
onClose fires between requestPromise resolving and success completing, didSuccessfullyRetry may still be false.
request(path, options) (lines 434–675) — the core method
Deprecated. Use useApiQuery or useMutation with apiOptions instead.
Returns a Request instance.
Phase 1 — URL + body (lines 435–454)
- Method defaults to
POST if data exists, else GET.
- Calls
buildRequestUrl() for full URL construction.
JSON.stringify(data) unless GET or FormData.
- GET with data: appends as query string (jQuery compat).
Phase 2 — Metrics + closures (lines 456–513)
metric.mark('api-request-start-<id>') at start.
successHandler: metric.measure('app.api.request-success') + wrapCallback(id, options.success).
errorHandler: metric.measure('app.api.request-error') + handleRequestError(...).
completeHandler: wrapCallback(id, options.complete, true) — the true deletes from activeRequests.
Phase 3 — Fetch construction (lines 516–538)
AbortController created unless skipAbort or unsupported.
- Headers:
this.headers merged with options.headers.
X-CSRFToken added for non-safe methods to similar origins.
fetch(fullUrl, { method, body, headers, credentials, signal }).
Phase 4 — Response parsing (lines 543–616)
response.text() always attempted first. Failure → ok = false.
- JSON parse skipped for 204 and 3xx. Parse failure handling:
- AbortError → error path.
- MIME is JSON + SyntaxError → error path (
'JSON parse error').
- Expected JSON + non-empty non-JSON → error path (
'JSON parse error. Possibly returned HTML').
- Empty body on 201 → silently succeeds with
responseJSON = undefined.
responseData is responseJSON if content-type includes json, else responseText.
Phase 5 — Dispatch (lines 618–669)
ok = true → successHandler(resp, statusText, responseData).
ok = false + status === 200 → Sentry capture with fingerprint '200 treated as error', tagged with endpoint and error reason (diagnostic).
ok = false → runs all globalErrorHandlers. If any returns true, the per-request error callback is skipped. Otherwise errorHandler(resp, statusText, errorReason).
- Always →
completeHandler(resp, statusText).
Fetch rejection handler (line 657)
Network failures and cancelled requests are silently swallowed by a no-op () => {}.
.catch handler (lines 662–669)
Logs to console.error. Captures to Sentry unless error.name === 'AbortError' or error.message === 'Response is undefined'.
Side effects summary
| Side Effect |
When |
metric.mark |
Request start |
metric.measure('app.api.request-success') |
On success |
metric.measure('app.api.request-error') |
On error |
metric('app.api.request-abort', 1) |
On cancel |
activeRequests[id] = request |
After fetch |
delete activeRequests[id] |
In complete handler |
Sentry.captureException |
200-treated-as-error, unexpected throws |
openSudo modal |
On sudo/superuser required |
window.location.assign |
On 401 sso-required |
apiNavigate |
On 401 member-disabled, or session expired in SPA |
Cookies.set('session_expired') |
On 401 (non-demo) |
Edge cases
- AbortError: suppressed in
.catch, does not go to Sentry.
- Undefined response:
new Error('Response is undefined') thrown, logged, not sent to Sentry.
- FormData body: not JSON-stringified; passed to fetch directly. Note: default
Content-Type: application/json header is still set from this.headers — callers should override.
- GET with data: data appended as query string, body is
undefined.
skipAbort: true: no AbortController created, but cancel() still sets alive = false (suppressing callbacks).
wrapCallback alive check: all callbacks are suppressed if request was cancelled before response arrives.
completeHandler cleanup: deletes from activeRequests even if request is dead.
requestPromise<IncludeAllArgsType>(path, options) (lines 683–723)
Deprecated. Promise wrapper around request().
- Creates
preservedError = new Error('API Request Error') synchronously for stack trace coalescence.
- Overrides
success and error callbacks to resolve/reject the Promise.
includeAllArgs: true → resolves with [data, textStatus, resp] (ApiResult).
includeAllArgs: false (default) → resolves with just data.
- Rejects with
new RequestError(method, path, preservedError, resp).
Edge cases:
- Caller-provided
success/error callbacks are silently ignored (overwritten).
- Unhandled rejections may be captured by Sentry's global handler.
resolveHostname(path, hostname?) — exported (lines 726–773)
Routes requests to the correct silo in multi-region deployments.
- Reads
configLinks (regionUrl, sentryUrl) and systemFeatures from ConfigStore.
- If no explicit
hostname and system:multi-region is enabled:
/_admin/ pages: skip routing (control silo handles region resolution).
- Control silo paths (via
detectControlSiloPath): route to sentryUrl.
- Everything else: route to
regionUrl.
- Dev-UI mode (
window.__SENTRY_DEV_UI):
- If hostname equals
sentryUrl, drop it (same-origin).
- Otherwise extract subdomain from
*.sentry.io and rewrite path to /region/<subdomain>/... for webpack proxy routing.
- If hostname is still set, prepend it to path.
Edge cases:
- Non-
*.sentry.io hostnames in dev-ui mode are prepended directly without subdomain extraction.
/_admin/ bypass means admin requests always go through control silo proxy.
- If
regionUrl/sentryUrl are not populated, multi-region logic is a no-op.
detectControlSiloPath(path) — internal (lines 775–787)
- Parses
path with new URL(path, 'https://sentry.io') to strip query strings.
- Strips leading
/ from pathname.
- Tests against 253 compiled
RegExp patterns from controlsiloUrlPatterns.
Patterns cover: auth, OAuth, SAML, admin, integrations, webhooks, user management, avatars, API tokens, broadcasts, Sentry Apps, and third-party provisioning (Heroku, Vercel, Stripe, etc.).
All patterns are anchored at ^ without a leading slash, matching the stripped pathname.
static/app/__mocks__/api.tsx — Mock API Client Reference
Jest mock that replaces sentry/api in tests. Provides a mock Client class that intercepts request() calls and resolves them against a registry of mock responses instead of making real HTTP requests.
Re-exports from real module (lines 6–9)
export const initApiClientErrorHandling = RealApi.initApiClientErrorHandling;
export const hasProjectBeenRenamed = RealApi.hasProjectBeenRenamed;
These two are passed through from the real api.tsx via jest.requireActual. Tests get the real global error handling and project-rename logic even when using the mock client.
respond(asyncDelay, fn, ...args) — internal helper (lines 11–26)
| Parameter |
Type |
Description |
asyncDelay |
undefined | number |
Delay in ms before calling the callback. undefined = synchronous. |
fn |
FunctionCallback | undefined |
The callback to invoke. If undefined, returns immediately (no-op). |
...args |
any[] |
Arguments forwarded to fn. |
Behavior:
- If
fn is falsy, returns immediately.
- If
asyncDelay is a number, wraps the call in setTimeout(() => fn(...args), asyncDelay).
- If
asyncDelay is undefined, calls fn(...args) synchronously.
Purpose: Controls whether mock responses resolve synchronously (default) or asynchronously (to test loading states, race conditions, etc.).
MatchCallable type (line 33)
type MatchCallable = (url: string, options: ApiNamespace.RequestOptions) => boolean;
A predicate function that receives the request URL and options and returns true if the request matches. Used in ResponseType.match arrays and by the matchQuery/matchData factories.
ResponseType interface (lines 36–55)
Extends ApiNamespace.ResponseMeta with mock-specific fields:
| Property |
Type |
Default (from addMockResponse) |
Description |
body |
any |
'' |
The mock response body. Can be a value or a function (url, options) => any for dynamic responses. |
callCount |
0 |
0 |
Incremented each time the mock is matched. Tracks how many times a mock was hit. |
headers |
Record<string, string> |
{} |
Response headers. Used by getResponseHeader. |
host |
string |
'' |
If non-empty, the mock only matches when options.host equals this value. |
match |
MatchCallable[] |
[] |
Array of predicates that all must return true for the mock to match. |
method |
string |
'GET' |
HTTP method to match against. |
statusCode |
number |
200 |
The mock response status code. >= 300 triggers the error path. |
url |
string |
'' |
The URL path to match against (exact string equality). |
asyncDelay? |
undefined | number |
Client.asyncDelay |
Per-response override for async delay. |
query? |
Record<string, ...> |
— |
Not used by matching logic directly; informational. Query matching is done via matchQuery in the match array. |
status |
(inherited) |
200 |
Maps to ResponseMeta.status. |
statusCode |
(own) |
200 |
The actual field used for status branching in request(). |
statusText |
(inherited) |
'OK' |
Maps to ResponseMeta.statusText. |
responseText |
(inherited) |
'' |
|
responseJSON |
(inherited) |
'' |
|
getResponseHeader |
(inherited) |
key lookup into headers |
Constructed by addMockResponse. |
compareRecord(want, check) — internal (lines 62–70)
Inputs: Two Record<string, any> objects.
Behavior: Iterates over every key/value pair in want. Uses lodash/isEqual (deep equality) to compare each against the corresponding key in check. Returns false on the first mismatch. Returns true if all entries match.
Key detail: Only checks keys present in want — extra keys in check are ignored. This means matchQuery({page: '1'}) will pass even if options.query also has {per_page: 25, cursor: 'abc'}.
afterEach cleanup hook (lines 72–85)
Runs after every test automatically:
- Checks
Client.errors (accumulated unmocked-request errors). If any exist, logs each via console.error, then clears the map.
- Calls
Client.clearMockResponses() to reset the mock registry.
This ensures:
- Unmocked API calls produce visible test output (even though the error can't be thrown from within the mock).
- Mock responses don't leak between tests.
Mock Client class (lines 87–314)
Implements ApiNamespace.Client. Replaces the real Client in all test files.
Instance properties
| Property |
Value |
Description |
activeRequests |
{} |
Empty record — mirrors real client interface. |
baseUrl |
'' |
Empty string (real client defaults to '/api/0'). |
headers |
{ Accept: ..., 'Content-Type': ... } |
Copy/paste of Client.JSON_HEADERS (can't import real one due to circular dependency). |
Static properties
| Property |
Type |
Initial |
Description |
mockResponses |
MockResponse[] |
[] |
Registry of [ResponseType, jest.Mock] tuples. Searched in order by findMockResponse. |
asyncDelay |
undefined | number |
undefined |
Global default async delay. undefined = synchronous. |
errors |
Record<string, Error> |
{} |
Accumulates errors for unmocked requests. Logged and cleared in afterEach. |
Client.clearMockResponses() — static (line 109)
Resets Client.mockResponses to []. Called in afterEach.
Client.matchQuery(query) — static (lines 118–124)
Input: query: Record<string, any> — the expected query parameters.
Returns: MatchCallable — a predicate (_url, options) => boolean.
Behavior: Calls compareRecord(query, options.query ?? {}). Returns true if every key/value in query exists and deeply equals the same key in options.query. Extra keys in options.query are ignored.
Usage in tests:
MockApiClient.addMockResponse({
url: '/api/0/issues/',
match: [MockApiClient.matchQuery({page: '1', per_page: '25'})],
body: [...],
});
Client.matchData(data) — static (lines 131–137)
Input: data: Record<string, any> — the expected request body fields.
Returns: MatchCallable — a predicate (_url, options) => boolean.
Behavior: Calls compareRecord(data, options.data ?? {}). Same partial-match semantics as matchQuery but against options.data.
Usage in tests:
MockApiClient.addMockResponse({
url: '/api/0/issues/',
method: 'POST',
match: [MockApiClient.matchData({status: 'resolved'})],
body: {...},
});
Client.addMockResponse(response) — static (lines 140–165)
Input: Partial<ResponseType> — any subset of mock response fields. All have defaults.
Returns: jest.Mock — the mock function that records calls. Can be asserted on with expect(mock).toHaveBeenCalledWith(url, options).
Behavior:
- Creates a fresh
jest.fn().
- Builds a complete
ResponseType by merging defaults with the provided response:
host: '', url: '', status: 200, statusCode: 200, statusText: 'OK'
responseText: '', responseJSON: '', body: '', method: 'GET'
callCount: 0, match: []
asyncDelay: falls back to response.asyncDelay ?? Client.asyncDelay
headers: falls back to response.headers ?? {}
getResponseHeader: closure that reads from response.headers
- Unshifts (prepends) the
[ResponseType, mock] tuple to Client.mockResponses.
Important: Insertion order. New mocks are prepended, so the most recently added mock is checked first. This means you can override a general mock with a more specific one added later — the later mock will match first.
Edge case: status and statusCode are both set to 200 by default, but the branching logic in request() only reads statusCode. If a caller sets status: 500 but not statusCode, the request will still be treated as successful.
Client.findMockResponse(url, options) — static (lines 167–180)
Inputs:
url: string — the request URL path.
options: Readonly<RequestOptions> — the request options.
Returns: MockResponse | undefined — the first matching [ResponseType, jest.Mock] tuple, or undefined.
Matching algorithm (evaluated in order, short-circuits on first match):
For each registered mock response:
- Host check: If
response.host is non-empty and options.host || '' does not equal it → skip.
- URL check: If
url !== response.url → skip. Exact string equality — no pattern matching, no query string stripping, no normalization.
- Method check: If
(options.method || 'GET') !== response.method → skip.
- Custom matchers:
response.match.every(matcher => matcher(url, options)) — all MatchCallable predicates must return true.
If all four checks pass, the mock is returned.
Key behaviors:
- URL matching is exact.
/api/0/issues/ does not match /api/0/issues (trailing slash matters).
- Method defaults to
'GET' if options.method is undefined.
- Host defaults to
'' if options.host is undefined.
- If
response.match is [] (the default), [].every(...) returns true — no custom matchers needed.
- Because mocks are unshifted (prepended), newer mocks take priority.
client.uniqueId() — instance (line 182)
Returns the hardcoded string '123'. Simplifies assertions by making request IDs deterministic.
client.clear() — instance (lines 190–192)
Same as real client: calls .cancel() on all activeRequests. In practice, activeRequests is always empty in the mock because request() doesn't populate it.
client.wrapCallback(id, func, cleanup) — instance (lines 194–207)
Simplified version of the real wrapCallback:
- Captures
Client.asyncDelay at wrap time.
- Returns a closure that:
a. Calls RealApi.hasProjectBeenRenamed(...args) — the real implementation. If it returns true, returns early (project-rename redirect).
b. Otherwise, calls respond(asyncDelay, func, ...args).
Differences from real:
- No alive-check (no
activeRequests[id] lookup).
- No cleanup (does not delete from
activeRequests).
- Uses the module-level
asyncDelay at wrap time, not at call time.
client.requestPromise(path, options) — instance (lines 209–228)
Promise wrapper around this.request(), mirroring the real implementation:
includeAllArgs: true → resolves with [data, ...args].
includeAllArgs: false → resolves with data.
- On error → rejects with the error object directly (no
RequestError wrapping like the real client).
Difference from real: The real client wraps errors in new RequestError(method, path, preservedError, resp). The mock rejects with the raw error response.
client.request(url, options) — instance (lines 233–309)
The core method. Replaces real HTTP with mock response lookup.
Step-by-step behavior:
-
Find mock: Calls Client.findMockResponse(url, options).
-
No mock found (lines 238–254):
- Creates
new Error('No mocked response found for request: METHOD /url').
- Stack trace manipulation: Finds the first
.spec. frame in the stack trace and trims everything above it. This makes the error point at the test file, not the mock internals.
- Does NOT throw. Instead, stores the error in
Client.errors[methodAndUrl]. The afterEach hook will console.error it later.
- Why: Throwing would be caught by the component's own error handling (which shows user-friendly messages), making the missing-mock error invisible to the test author. The deferred logging approach ensures it's always visible.
-
Mock found — record the call (line 259):
- Calls
mock(url, options) — records the call on the jest.fn() so tests can assert expect(mock).toHaveBeenCalledWith(...).
-
Resolve body (lines 261–262):
- If
response.body is a function, calls response.body(url, options) to compute the body dynamically.
- Otherwise uses
response.body as-is.
-
Error path (response.statusCode >= 300, lines 264–291):
- Increments
response.callCount.
- Constructs an error object by creating a
RequestError and then using Object.assign to bolt on extra fields:
status, responseText (JSON-stringified body), responseJSON (raw body).
- Stub methods:
overrideMimeType, abort, then, error — all no-ops. Remnants of the old jQuery XHR interface.
- Calls
this.handleRequestError(...) which is the real Client.prototype.handleRequestError (line 311). This means:
SUDO_REQUIRED / SUPERUSER_REQUIRED responses trigger the real sudo modal flow.
- Other errors are routed through
wrapCallback → options.error.
-
Success path (statusCode < 300, lines 292–305):
- Increments
response.callCount.
- Calls
respond(response.asyncDelay, options.success, body, {}, responseMeta).
- The
responseMeta passed to success is a minimal object: { getResponseHeader, statusCode, status }. Notably missing: statusText, responseText, responseJSON — tests relying on these fields from the success callback's third argument will get undefined.
-
Complete callback (line 308):
- Always called:
respond(response?.asyncDelay, options.complete).
- Called with no arguments (the real client passes
(resp, textStatus)). Tests that rely on complete callback arguments will get undefined.
Differences from real Client.request():
| Aspect |
Real |
Mock |
| HTTP |
fetch() call |
Mock response lookup |
| URL construction |
buildRequestUrl + resolveHostname |
Exact string match on url |
| CSRF |
Attaches X-CSRFToken header |
Not applicable |
| AbortController |
Created unless skipAbort |
Not created |
activeRequests tracking |
Populated and cleaned up |
Never populated |
wrapCallback alive check |
Yes |
No |
| Success callback args |
(body, statusText, fullResponseMeta) |
(body, {}, minimalMeta) |
| Complete callback args |
(responseMeta, statusText) |
None |
| Error wrapping |
Goes through full parse/dispatch pipeline |
Directly constructs RequestError + Object.assign |
handleRequestError |
Own implementation |
Delegates to real implementation |
| Metrics |
metric.mark, metric.measure |
None |
| Sentry captures |
On 200-as-error, unexpected throws |
None |
preservedError |
Created in requestPromise |
Not created |
| Unmocked requests |
N/A |
Deferred console.error via Client.errors |
client.handleRequestError — instance (line 311)
handleRequestError = RealApi.Client.prototype.handleRequestError;
Directly assigned from the real client's prototype. This means the mock uses the real sudo/superuser retry logic, including openSudo() modal and requestPromise retry.
Implication: If a test returns a mock with statusCode: 403 and responseJSON.detail.code === 'sudo-required', the real sudo modal will be triggered. Tests must mock openSudo separately if they don't want this.
How matching works end-to-end
When test code triggers a component that calls api.request('/api/0/issues/', {method: 'GET', query: {page: '1'}}):
Client.findMockResponse iterates mockResponses (newest first).
- For each, checks: host match → URL exact match → method match → all custom matchers.
- Custom matchers like
matchQuery({page: '1'}) call compareRecord({page: '1'}, options.query) which uses lodash/isEqual per key.
- First full match wins.
- If no match: error stored in
Client.errors, logged after test.
Matching pitfalls:
- URL must be exact —
/api/0/issues vs /api/0/issues/ will not match.
- Method defaults to
'GET' in both the mock and the lookup, so omitting method works for GET requests.
matchQuery and matchData are partial matchers — they only check keys you specify. To assert no extra keys, you'd need a custom MatchCallable.
- Multiple mocks for the same URL+method: the last one added (first in the array) wins. Override by adding a more specific mock after a general one.
response.body as a function is resolved after matching, so the function receives the actual (url, options) and can return different bodies per call.
Error handling differences: real vs mock
| Scenario |
Real Client |
Mock Client |
| Unmocked endpoint |
N/A |
Error stored in Client.errors, logged after test |
| Status >= 300 |
Full response parsing, global handlers, handleRequestError |
Constructs RequestError, delegates to real handleRequestError |
| Sudo required |
openSudo modal, retry via requestPromise |
Same (uses real handleRequestError) |
| Status < 300 |
wrapCallback → alive check → project-rename check → success |
respond(asyncDelay, options.success, ...) directly |
| Network failure |
Silently swallowed |
N/A (no network) |
| AbortError |
Suppressed, not sent to Sentry |
N/A (no abort support) |
complete callback |
Called with (responseMeta, statusText) |
Called with no arguments |
| 200 treated as error |
Sentry capture + error path |
N/A (no response parsing) |
sentryCellFetch vs api.requestPromise — Behavioral Comparison
The Key Difference: What Happens When an Error Handler Suppresses an Error
When a registered error handler (auth redirect, SSO, project rename, etc.) handles a non-2xx response and signals "I handled this," the two systems diverge:
api.requestPromise() — Promise Hangs Forever
In api.tsx line 646–653, when a globalErrorHandler returns true:
ok = false path:
1. successHandler is NOT called (we're in the error branch)
2. globalErrorHandlers run — one returns true → shouldSkipErrorHandler = true
3. errorHandler is NOT called (skipped by the guard)
4. completeHandler IS called (but requestPromise doesn't use it)
Since requestPromise wraps request() by overriding only success (→ resolve) and error (→ reject), and neither callback fires, the returned Promise never settles. It hangs indefinitely.
In practice this is "fine" because the handler is doing a hard redirect (window.location.assign, window.location.reload, or SPA navigate to /auth/login/), so the hanging promise is abandoned when the page navigates away.
sentryCellFetch() — Promise Resolves with {json: undefined}
In sentryCellFetch.tsx handleErrorResponse() (line 192–238), when a handler suppresses an error it returns a value:
// e.g., onAuthError returns true
if (responseMeta.status === 401 && errorHandlers?.onAuthError?.(responseMeta, options)) {
return {headers: buildResponseHeaders(response), json: undefined as unknown};
}
The promise resolves successfully with {headers: {...}, json: undefined}.
Why This Matters
| Scenario |
requestPromise |
sentryCellFetch |
| Auth redirect (401) |
Promise hangs; page navigates away |
Promise resolves with undefined data |
| SSO required |
Promise hangs; browser redirects |
Promise resolves with undefined data |
| Project renamed |
Promise hangs; redirect happens |
Promise resolves with undefined data |
| Member over limit |
Promise hangs; SPA navigates |
Promise resolves with undefined data |
Consequences for React Query
With sentryCellFetch as a queryFn, a suppressed error means React Query sees a successful query that returned undefined. This means:
- Caching: React Query caches
undefined as valid data. If the redirect doesn't complete before a re-render, or if the user navigates back, the cached undefined may be served.
- Loading states: The query transitions from
pending → success with data: undefined, so components render their "data loaded" state with no data instead of showing a loading spinner.
- Retries: React Query won't retry because the query "succeeded."
onSuccess callbacks: Any configured onSuccess or dependent queries will fire with undefined input.
With requestPromise, none of these happen because the promise never settles — React Query stays in pending state (showing a loading spinner) until the page navigates away.
Other Differences
| Aspect |
api.requestPromise() |
sentryCellFetch() |
| Error type |
RequestError(method, path, preservedError, resp) |
RequestError(method, fullUrl, new Error('Request failed'), resp) |
| Stack trace |
preservedError created before async call — captures call site |
new Error('Request failed') created at throw time — captures internal stack only |
| Network failure |
Silently swallowed (fetch rejection handler is () => {}) |
Propagates as thrown error |
Request Cancellation: Client.clear() vs TanStack Query's AbortSignal
api.requestPromise — Manual cancellation via Client.clear()
The old Client class tracks every in-flight request in an activeRequests map. Client.clear() iterates that map and aborts all of them. Callers use this in three patterns:
- Unmount cleanup —
useApi() calls api.clear() in its useEffect cleanup (unless persistInFlight is set). Legacy class components (DeprecatedAsyncComponent, PluginComponentBase, SelectAsyncControl) call it in componentWillUnmount().
- "Cancel previous before new search" — Hooks like
useTeams, useProjects, and SearchBar call api.clear() before issuing a new search request, preventing race conditions where a slow earlier response overwrites a fast later one.
- "Cancel previous before new data fetch" — Chart/table components (
EventsRequest, ReleaseSeries, DiscoverQuery, etc.) clear before re-fetching.
There is also a per-request Request.cancel() used only by CursorPoller.disable() for surgical cancellation of a single polling request.
sentryCellFetch — No equivalent needed
sentryCellFetch does not maintain an activeRequests map and has no clear() or cancel() method. TanStack Query handles all three patterns natively:
| Pattern |
Old path (Client) |
New path (TanStack Query) |
| Unmount cleanup |
useApi() effect cleanup → api.clear() |
Query is automatically cancelled when the component unmounts and no other observers remain |
| Cancel stale search |
Manual api.clear() before new fetch |
Changing the queryKey (e.g., search term) automatically cancels the previous in-flight query and starts a new one |
| Cancel stale data fetch |
Manual api.clear() before re-fetch |
Same — queryKey change triggers automatic cancellation via the AbortSignal passed to queryFn |
| Surgical cancel (polling) |
request.cancel() on a single Request |
queryClient.cancelQueries({queryKey}) or disabling the query |
The AbortSignal that TanStack Query passes into queryFn (and which sentryCellFetch forwards to fetch()) is the mechanism that makes this work. When React Query decides a request is no longer needed, it aborts the signal, and the browser cancels the underlying fetch. No manual bookkeeping required.
Test coverage implication
api.spec.tsx tests Client.clear() aborting active requests. The parity spec (fetchParity.spec.tsx) intentionally does not cover this because sentryCellFetch has no equivalent — the responsibility belongs to TanStack Query, not the fetch function.
Error Handler Registration
api.requestPromise — initApiClientErrorHandling()
Pushes a single handler into globalErrorHandlers array. The handler covers:
- 401 +
sso-required → window.location.assign(loginUrl)
- 401 +
member-disabled-over-limit → SPA navigate to extra.next
- 401 (other) → set
session_expired cookie, reload or navigate to /auth/login/
Handlers return boolean — true to suppress the per-request error callback.
sentryCellFetch — configureSentryCellFetch() + createDefaultErrorHandlers()
Error handlers are injected via config, with named hooks:
onSudoRequired → opens sudo modal, returns Promise<ApiResponse> (owns the retry)
onProjectRenamed → calls redirectToProject(slug), returns true
onAuthError → same logic as the old handler (anon pages, SSO, member limit, session expired)
onError → generic catch-all
Key structural difference: sudo/superuser handling lives inside handleRequestError in the old client (part of Client class), but is a pluggable onSudoRequired handler in sentryCellFetch.
The problem is that our core api & fetch helper is tightly coupled to some UI elements that will open depending on specific error codes that are returned from the server. Some handlers are configurable, and not included in _admin, not this is not consistently implemented. There are error handlers are multiple layers which makes refactoring in pieces tedious and risky.
Ideally the core fetch helper would only directly deal with preparing the target url for the request, and deserializing the response, allowing handlers for each situation to be injected for different scenarios. Currently we have the 'App' scenario and '_admin' with different sets of handlers. In future I'd also like to implement a ServiceWorker scenario where error handlers are de-coupled from the UI completely.
Read blow for an AI assisted reference of hose api.tsx works.
Followed by another document which compares the new
sentryCellFetchwithapi.requestPromise()static/app/api.tsx— Complete API ReferenceSentry's core HTTP client. Used throughout the frontend to communicate with the backend REST API.
This document also covers the Jest mock at
static/app/__mocks__/api.tsx.Module-level:
apiNavigate+setApiNavigate(lines 29–33)apiNavigate— module-privateReactRouter3Navigate | null, initiallynull. Holds a React Router navigate function so this non-React module can do client-side redirects.setApiNavigate(navigate)— exported setter. Called once at app bootstrap. All usages are guarded with?., so if never set, redirects silently no-op.Edge case: calling it twice silently overwrites.
Requestclass (lines 35–61)alivebooleantrueuntil.cancel()is calledrequestPromisePromise<Response>aborterAbortController?skipAbort: trueconstructor(requestPromise, aborter?)— stores both, setsalive = true.cancel()— setsalive = false, callsaborter?.abort(), emitsmetric('app.api.request-abort', 1). If the request already settled, the abort is a no-op.ApiResult<Data>type (lines 63–67)The "full" resolution type when
requestPromiseis called withincludeAllArgs: true.ResponseMeta<R>type (lines 69–90)statusResponse['status']statusTextResponse['statusText']responseJSONRresponseTextstringgetResponseHeader(header: string) => string | nullPure data shape wrapping the response for the jQuery-compat callback API.
csrfSafeMethod(method?)— internal (lines 95–98)Returns
trueforGET|HEAD|OPTIONS|TRACE. Used to decide whether to attachX-CSRFToken. Pure function;undefinedinput returnsfalse.isSimilarOrigin(target, origin)— exported (lines 105–126)Checks if two URLs share an ancestor domain (parent-child or sibling subdomains).
Behavior:
new URL. Relativetargetis resolved againstorigin.trueif either hostname.endsWith()the other (parent-child or exact match).falseif either has < 2 segments after stripping.Edge cases: Relative paths always return
true. Barelocalhostreturnsfalsein the sibling check.new URL()throws iforiginis invalid.ALLOWED_ANON_PAGES— internal (lines 129–135)Array of
RegExpfor paths that don't trigger auth redirects on 401:/accept/,/share/,/auth/login/,/join-request/,/unsubscribe/.globalErrorHandlers— internal (lines 140–142)Chain-of-responsibility registry. Handlers return
trueto suppress the per-request error callback. Populated byinitApiClientErrorHandling.initApiClientErrorHandling()— exported (lines 144–196)Pushes a 401 handler into
globalErrorHandlers. Should be called exactly once at bootstrap (no duplicate guard).The handler's logic on every non-2xx response:
resp.status !== 401or page is inALLOWED_ANON_PAGES.options.allowAuthErroristrue.codeissudo-required,ignore,2fa-required, orapp-connect-authentication-error.sso-required→window.location.assign(extra.loginUrl). Returnstrue.member-disabled-over-limit→apiNavigate?.(extra.next, {replace: true}). Returnstrue.session_expiredcookie (unless demo mode), then either navigates to/auth/login/(SPA) orwindow.location.reload(). Returnstrue.Returns
true= skip per-request error callback. Returnsfalse= let it through.Side effects: May set
session_expiredcookie, hard-redirect the browser, or trigger SPA navigation.Edge cases:
apiNavigatenot set → SPA navigation silently no-ops.extra.loginUrlorextra.nextaccessed without null-checking →TypeErrorifextraisundefined.buildRequestUrl(baseUrl, path, options)— internal (lines 201–226)options.queryviaqs.stringify. On failure, captures to Sentry and re-throws.baseUrlifpathdoesn't already contain it.resolveHostname(fullUrl, options.host)for multi-region routing.?or&as needed.hasProjectBeenRenamed(response)— exported (lines 234–249)Checks
response.responseJSON.detail.code === PROJECT_MOVED. If so, callsredirectToProject(slug)and returnstrue. Otherwise returnsfalse.Historical note: this may never fire in practice because browsers auto-follow 302 redirects.
RequestCallbackstype (lines 252–267)success?(data: any, textStatus?: string, resp?: ResponseMeta) => voiderror?(...args: any[]) => void(loosely typed)complete?(resp: ResponseMeta, textStatus: string) => voidRequestOptionstype (lines 269–308)Extends
RequestCallbackswith:allowAuthErrorbooleanfalsedataanyheadersRecord<string, string>hoststringmethod'DELETE' | 'GET' | 'POST' | 'PUT'preservedErrorErrorqueryRecord<string, any>skipAbortbooleanfalseclient.clear()ClientOptionstype — internal (lines 310–323)baseUrlstring'/api/0'credentialsRequestCredentials'include'headersHeadersInitClient.JSON_HEADERSHandleRequestErrorOptionstype — internal (lines 325–329)idstringpathstringrequestOptionsReadonly<RequestOptions>Clientclass (lines 336–723)Static:
Client.JSON_HEADERS(lines 342–345)Constructor (
options: ClientOptions = {}) (lines 347–352)baseUrl'/api/0'headersClient.JSON_HEADERScredentials'include'activeRequests{}wrapCallback<T>(id, func, cleanup = false)(lines 354–379)Returns a closure that:
activeRequests[id].cleanup = true, deletes the entry fromactiveRequests.reqis missing oralive === false, returns early (callback suppressed).hasProjectBeenRenamed(...args)returnstrue, returns early (redirect handled).func?.apply(req, args).Edge cases:
func = undefined→func?.apply()is a no-op.cleanup = true→ second call findsreq = undefined, returns early. Idempotent.@ts-expect-erroron line 372 suppresses a tuple-spread type error forhasProjectBeenRenamed.clear()(lines 384–387)Calls
.cancel()on everyRequestinactiveRequests. Does not remove entries (they remain as dead references; the complete handler cleans up).Side effects: Sets
alive = falseon all requests, sends abort signals, emitsapp.api.request-abortper request.handleRequestError({id, path, requestOptions}, response, textStatus, errorThrown)(lines 389–426)response.responseJSON.detail.code.SUDO_REQUIREDorSUPERUSER_REQUIRED):openSudo().retryRequest: re-issues request viathis.requestPromise(). On success callsoptions.success, on failure callsoptions.error.onClose: callsoptions.error(response)if retry didn't succeed.wrapCallback.options.errorviawrapCallback(no cleanup) and calls it with(response, textStatus, errorThrown).Edge cases:
wrapCallbackguards (no alive-check or project-rename check).onClosefires betweenrequestPromiseresolving andsuccesscompleting,didSuccessfullyRetrymay still befalse.request(path, options)(lines 434–675) — the core methodDeprecated. Use
useApiQueryoruseMutationwithapiOptionsinstead.Returns a
Requestinstance.Phase 1 — URL + body (lines 435–454)
POSTifdataexists, elseGET.buildRequestUrl()for full URL construction.JSON.stringify(data)unless GET or FormData.Phase 2 — Metrics + closures (lines 456–513)
metric.mark('api-request-start-<id>')at start.successHandler:metric.measure('app.api.request-success')+wrapCallback(id, options.success).errorHandler:metric.measure('app.api.request-error')+handleRequestError(...).completeHandler:wrapCallback(id, options.complete, true)— thetruedeletes fromactiveRequests.Phase 3 — Fetch construction (lines 516–538)
AbortControllercreated unlessskipAbortor unsupported.this.headersmerged withoptions.headers.X-CSRFTokenadded for non-safe methods to similar origins.fetch(fullUrl, { method, body, headers, credentials, signal }).Phase 4 — Response parsing (lines 543–616)
response.text()always attempted first. Failure →ok = false.'JSON parse error').'JSON parse error. Possibly returned HTML').responseJSON = undefined.responseDataisresponseJSONif content-type includesjson, elseresponseText.Phase 5 — Dispatch (lines 618–669)
ok = true→successHandler(resp, statusText, responseData).ok = false+status === 200→ Sentry capture with fingerprint'200 treated as error', tagged with endpoint and error reason (diagnostic).ok = false→ runs allglobalErrorHandlers. If any returnstrue, the per-request error callback is skipped. OtherwiseerrorHandler(resp, statusText, errorReason).completeHandler(resp, statusText).Fetch rejection handler (line 657)
Network failures and cancelled requests are silently swallowed by a no-op
() => {}..catchhandler (lines 662–669)Logs to
console.error. Captures to Sentry unlesserror.name === 'AbortError'orerror.message === 'Response is undefined'.Side effects summary
metric.markmetric.measure('app.api.request-success')metric.measure('app.api.request-error')metric('app.api.request-abort', 1)activeRequests[id] = requestdelete activeRequests[id]Sentry.captureExceptionopenSudomodalwindow.location.assignapiNavigateCookies.set('session_expired')Edge cases
.catch, does not go to Sentry.new Error('Response is undefined')thrown, logged, not sent to Sentry.Content-Type: application/jsonheader is still set fromthis.headers— callers should override.undefined.skipAbort: true: noAbortControllercreated, butcancel()still setsalive = false(suppressing callbacks).wrapCallbackalive check: all callbacks are suppressed if request was cancelled before response arrives.completeHandlercleanup: deletes fromactiveRequestseven if request is dead.requestPromise<IncludeAllArgsType>(path, options)(lines 683–723)Deprecated. Promise wrapper around
request().preservedError = new Error('API Request Error')synchronously for stack trace coalescence.successanderrorcallbacks to resolve/reject the Promise.includeAllArgs: true→ resolves with[data, textStatus, resp](ApiResult).includeAllArgs: false(default) → resolves with justdata.new RequestError(method, path, preservedError, resp).Edge cases:
success/errorcallbacks are silently ignored (overwritten).resolveHostname(path, hostname?)— exported (lines 726–773)Routes requests to the correct silo in multi-region deployments.
configLinks(regionUrl,sentryUrl) andsystemFeaturesfromConfigStore.hostnameandsystem:multi-regionis enabled:/_admin/pages: skip routing (control silo handles region resolution).detectControlSiloPath): route tosentryUrl.regionUrl.window.__SENTRY_DEV_UI):sentryUrl, drop it (same-origin).*.sentry.ioand rewrite path to/region/<subdomain>/...for webpack proxy routing.Edge cases:
*.sentry.iohostnames in dev-ui mode are prepended directly without subdomain extraction./_admin/bypass means admin requests always go through control silo proxy.regionUrl/sentryUrlare not populated, multi-region logic is a no-op.detectControlSiloPath(path)— internal (lines 775–787)pathwithnew URL(path, 'https://sentry.io')to strip query strings./from pathname.RegExppatterns fromcontrolsiloUrlPatterns.Patterns cover: auth, OAuth, SAML, admin, integrations, webhooks, user management, avatars, API tokens, broadcasts, Sentry Apps, and third-party provisioning (Heroku, Vercel, Stripe, etc.).
All patterns are anchored at
^without a leading slash, matching the stripped pathname.static/app/__mocks__/api.tsx— Mock API Client ReferenceJest mock that replaces
sentry/apiin tests. Provides a mockClientclass that interceptsrequest()calls and resolves them against a registry of mock responses instead of making real HTTP requests.Re-exports from real module (lines 6–9)
These two are passed through from the real
api.tsxviajest.requireActual. Tests get the real global error handling and project-rename logic even when using the mock client.respond(asyncDelay, fn, ...args)— internal helper (lines 11–26)asyncDelayundefined | numberundefined= synchronous.fnFunctionCallback | undefinedundefined, returns immediately (no-op)....argsany[]fn.Behavior:
fnis falsy, returns immediately.asyncDelayis anumber, wraps the call insetTimeout(() => fn(...args), asyncDelay).asyncDelayisundefined, callsfn(...args)synchronously.Purpose: Controls whether mock responses resolve synchronously (default) or asynchronously (to test loading states, race conditions, etc.).
MatchCallabletype (line 33)A predicate function that receives the request URL and options and returns
trueif the request matches. Used inResponseType.matcharrays and by thematchQuery/matchDatafactories.ResponseTypeinterface (lines 36–55)Extends
ApiNamespace.ResponseMetawith mock-specific fields:addMockResponse)bodyany''(url, options) => anyfor dynamic responses.callCount00headersRecord<string, string>{}getResponseHeader.hoststring''options.hostequals this value.matchMatchCallable[][]truefor the mock to match.methodstring'GET'statusCodenumber200>= 300triggers the error path.urlstring''asyncDelay?undefined | numberClient.asyncDelayquery?Record<string, ...>matchQueryin thematcharray.status200ResponseMeta.status.statusCode200request().statusText'OK'ResponseMeta.statusText.responseText''responseJSON''getResponseHeaderheadersaddMockResponse.compareRecord(want, check)— internal (lines 62–70)Inputs: Two
Record<string, any>objects.Behavior: Iterates over every key/value pair in
want. Useslodash/isEqual(deep equality) to compare each against the corresponding key incheck. Returnsfalseon the first mismatch. Returnstrueif all entries match.Key detail: Only checks keys present in
want— extra keys incheckare ignored. This meansmatchQuery({page: '1'})will pass even ifoptions.queryalso has{per_page: 25, cursor: 'abc'}.afterEachcleanup hook (lines 72–85)Runs after every test automatically:
Client.errors(accumulated unmocked-request errors). If any exist, logs each viaconsole.error, then clears the map.Client.clearMockResponses()to reset the mock registry.This ensures:
Mock
Clientclass (lines 87–314)Implements
ApiNamespace.Client. Replaces the realClientin all test files.Instance properties
activeRequests{}baseUrl'''/api/0').headers{ Accept: ..., 'Content-Type': ... }Client.JSON_HEADERS(can't import real one due to circular dependency).Static properties
mockResponsesMockResponse[][][ResponseType, jest.Mock]tuples. Searched in order byfindMockResponse.asyncDelayundefined | numberundefinedundefined= synchronous.errorsRecord<string, Error>{}afterEach.Client.clearMockResponses()— static (line 109)Resets
Client.mockResponsesto[]. Called inafterEach.Client.matchQuery(query)— static (lines 118–124)Input:
query: Record<string, any>— the expected query parameters.Returns:
MatchCallable— a predicate(_url, options) => boolean.Behavior: Calls
compareRecord(query, options.query ?? {}). Returnstrueif every key/value inqueryexists and deeply equals the same key inoptions.query. Extra keys inoptions.queryare ignored.Usage in tests:
Client.matchData(data)— static (lines 131–137)Input:
data: Record<string, any>— the expected request body fields.Returns:
MatchCallable— a predicate(_url, options) => boolean.Behavior: Calls
compareRecord(data, options.data ?? {}). Same partial-match semantics asmatchQuerybut againstoptions.data.Usage in tests:
Client.addMockResponse(response)— static (lines 140–165)Input:
Partial<ResponseType>— any subset of mock response fields. All have defaults.Returns:
jest.Mock— the mock function that records calls. Can be asserted on withexpect(mock).toHaveBeenCalledWith(url, options).Behavior:
jest.fn().ResponseTypeby merging defaults with the providedresponse:host: '',url: '',status: 200,statusCode: 200,statusText: 'OK'responseText: '',responseJSON: '',body: '',method: 'GET'callCount: 0,match: []asyncDelay: falls back toresponse.asyncDelay ?? Client.asyncDelayheaders: falls back toresponse.headers ?? {}getResponseHeader: closure that reads fromresponse.headers[ResponseType, mock]tuple toClient.mockResponses.Important: Insertion order. New mocks are prepended, so the most recently added mock is checked first. This means you can override a general mock with a more specific one added later — the later mock will match first.
Edge case:
statusandstatusCodeare both set to200by default, but the branching logic inrequest()only readsstatusCode. If a caller setsstatus: 500but notstatusCode, the request will still be treated as successful.Client.findMockResponse(url, options)— static (lines 167–180)Inputs:
url: string— the request URL path.options: Readonly<RequestOptions>— the request options.Returns:
MockResponse | undefined— the first matching[ResponseType, jest.Mock]tuple, orundefined.Matching algorithm (evaluated in order, short-circuits on first match):
For each registered mock response:
response.hostis non-empty andoptions.host || ''does not equal it → skip.url !== response.url→ skip. Exact string equality — no pattern matching, no query string stripping, no normalization.(options.method || 'GET') !== response.method→ skip.response.match.every(matcher => matcher(url, options))— allMatchCallablepredicates must returntrue.If all four checks pass, the mock is returned.
Key behaviors:
/api/0/issues/does not match/api/0/issues(trailing slash matters).'GET'ifoptions.methodis undefined.''ifoptions.hostis undefined.response.matchis[](the default),[].every(...)returnstrue— no custom matchers needed.client.uniqueId()— instance (line 182)Returns the hardcoded string
'123'. Simplifies assertions by making request IDs deterministic.client.clear()— instance (lines 190–192)Same as real client: calls
.cancel()on allactiveRequests. In practice,activeRequestsis always empty in the mock becauserequest()doesn't populate it.client.wrapCallback(id, func, cleanup)— instance (lines 194–207)Simplified version of the real
wrapCallback:Client.asyncDelayat wrap time.a. Calls
RealApi.hasProjectBeenRenamed(...args)— the real implementation. If it returnstrue, returns early (project-rename redirect).b. Otherwise, calls
respond(asyncDelay, func, ...args).Differences from real:
activeRequests[id]lookup).activeRequests).asyncDelayat wrap time, not at call time.client.requestPromise(path, options)— instance (lines 209–228)Promise wrapper around
this.request(), mirroring the real implementation:includeAllArgs: true→ resolves with[data, ...args].includeAllArgs: false→ resolves withdata.RequestErrorwrapping like the real client).Difference from real: The real client wraps errors in
new RequestError(method, path, preservedError, resp). The mock rejects with the raw error response.client.request(url, options)— instance (lines 233–309)The core method. Replaces real HTTP with mock response lookup.
Step-by-step behavior:
Find mock: Calls
Client.findMockResponse(url, options).No mock found (lines 238–254):
new Error('No mocked response found for request: METHOD /url')..spec.frame in the stack trace and trims everything above it. This makes the error point at the test file, not the mock internals.Client.errors[methodAndUrl]. TheafterEachhook willconsole.errorit later.Mock found — record the call (line 259):
mock(url, options)— records the call on thejest.fn()so tests can assertexpect(mock).toHaveBeenCalledWith(...).Resolve body (lines 261–262):
response.bodyis a function, callsresponse.body(url, options)to compute the body dynamically.response.bodyas-is.Error path (
response.statusCode >= 300, lines 264–291):response.callCount.RequestErrorand then usingObject.assignto bolt on extra fields:status,responseText(JSON-stringified body),responseJSON(raw body).overrideMimeType,abort,then,error— all no-ops. Remnants of the old jQuery XHR interface.this.handleRequestError(...)which is the realClient.prototype.handleRequestError(line 311). This means:SUDO_REQUIRED/SUPERUSER_REQUIREDresponses trigger the real sudo modal flow.wrapCallback→options.error.Success path (
statusCode < 300, lines 292–305):response.callCount.respond(response.asyncDelay, options.success, body, {}, responseMeta).responseMetapassed to success is a minimal object:{ getResponseHeader, statusCode, status }. Notably missing:statusText,responseText,responseJSON— tests relying on these fields from the success callback's third argument will getundefined.Complete callback (line 308):
respond(response?.asyncDelay, options.complete).(resp, textStatus)). Tests that rely on complete callback arguments will getundefined.Differences from real
Client.request():fetch()callbuildRequestUrl+resolveHostnameurlX-CSRFTokenheaderskipAbortactiveRequeststrackingwrapCallbackalive check(body, statusText, fullResponseMeta)(body, {}, minimalMeta)(responseMeta, statusText)RequestError+Object.assignhandleRequestErrormetric.mark,metric.measurepreservedErrorrequestPromiseconsole.errorviaClient.errorsclient.handleRequestError— instance (line 311)Directly assigned from the real client's prototype. This means the mock uses the real sudo/superuser retry logic, including
openSudo()modal andrequestPromiseretry.Implication: If a test returns a mock with
statusCode: 403andresponseJSON.detail.code === 'sudo-required', the real sudo modal will be triggered. Tests must mockopenSudoseparately if they don't want this.How matching works end-to-end
When test code triggers a component that calls
api.request('/api/0/issues/', {method: 'GET', query: {page: '1'}}):Client.findMockResponseiteratesmockResponses(newest first).matchQuery({page: '1'})callcompareRecord({page: '1'}, options.query)which useslodash/isEqualper key.Client.errors, logged after test.Matching pitfalls:
/api/0/issuesvs/api/0/issues/will not match.'GET'in both the mock and the lookup, so omittingmethodworks for GET requests.matchQueryandmatchDataare partial matchers — they only check keys you specify. To assert no extra keys, you'd need a customMatchCallable.response.bodyas a function is resolved after matching, so the function receives the actual(url, options)and can return different bodies per call.Error handling differences: real vs mock
Client.errors, logged after testhandleRequestErrorRequestError, delegates to realhandleRequestErroropenSudomodal, retry viarequestPromisehandleRequestError)wrapCallback→ alive check → project-rename check →successrespond(asyncDelay, options.success, ...)directlycompletecallback(responseMeta, statusText)sentryCellFetchvsapi.requestPromise— Behavioral ComparisonThe Key Difference: What Happens When an Error Handler Suppresses an Error
When a registered error handler (auth redirect, SSO, project rename, etc.) handles a non-2xx response and signals "I handled this," the two systems diverge:
api.requestPromise()— Promise Hangs ForeverIn
api.tsxline 646–653, when aglobalErrorHandlerreturnstrue:Since
requestPromisewrapsrequest()by overriding onlysuccess(→ resolve) anderror(→ reject), and neither callback fires, the returned Promise never settles. It hangs indefinitely.In practice this is "fine" because the handler is doing a hard redirect (
window.location.assign,window.location.reload, or SPA navigate to/auth/login/), so the hanging promise is abandoned when the page navigates away.sentryCellFetch()— Promise Resolves with{json: undefined}In
sentryCellFetch.tsxhandleErrorResponse()(line 192–238), when a handler suppresses an error it returns a value:The promise resolves successfully with
{headers: {...}, json: undefined}.Why This Matters
requestPromisesentryCellFetchundefineddataundefineddataundefineddataundefineddataConsequences for React Query
With
sentryCellFetchas aqueryFn, a suppressed error means React Query sees a successful query that returnedundefined. This means:undefinedas valid data. If the redirect doesn't complete before a re-render, or if the user navigates back, the cachedundefinedmay be served.pending→successwithdata: undefined, so components render their "data loaded" state with no data instead of showing a loading spinner.onSuccesscallbacks: Any configuredonSuccessor dependent queries will fire withundefinedinput.With
requestPromise, none of these happen because the promise never settles — React Query stays inpendingstate (showing a loading spinner) until the page navigates away.Other Differences
api.requestPromise()sentryCellFetch()RequestError(method, path, preservedError, resp)RequestError(method, fullUrl, new Error('Request failed'), resp)preservedErrorcreated before async call — captures call sitenew Error('Request failed')created at throw time — captures internal stack only() => {})Request Cancellation:
Client.clear()vs TanStack Query'sAbortSignalapi.requestPromise— Manual cancellation viaClient.clear()The old
Clientclass tracks every in-flight request in anactiveRequestsmap.Client.clear()iterates that map and aborts all of them. Callers use this in three patterns:useApi()callsapi.clear()in itsuseEffectcleanup (unlesspersistInFlightis set). Legacy class components (DeprecatedAsyncComponent,PluginComponentBase,SelectAsyncControl) call it incomponentWillUnmount().useTeams,useProjects, andSearchBarcallapi.clear()before issuing a new search request, preventing race conditions where a slow earlier response overwrites a fast later one.EventsRequest,ReleaseSeries,DiscoverQuery, etc.) clear before re-fetching.There is also a per-request
Request.cancel()used only byCursorPoller.disable()for surgical cancellation of a single polling request.sentryCellFetch— No equivalent neededsentryCellFetchdoes not maintain anactiveRequestsmap and has noclear()orcancel()method. TanStack Query handles all three patterns natively:Client)useApi()effect cleanup →api.clear()api.clear()before new fetchqueryKey(e.g., search term) automatically cancels the previous in-flight query and starts a new oneapi.clear()before re-fetchqueryKeychange triggers automatic cancellation via theAbortSignalpassed toqueryFnrequest.cancel()on a singleRequestqueryClient.cancelQueries({queryKey})or disabling the queryThe
AbortSignalthat TanStack Query passes intoqueryFn(and whichsentryCellFetchforwards tofetch()) is the mechanism that makes this work. When React Query decides a request is no longer needed, it aborts the signal, and the browser cancels the underlying fetch. No manual bookkeeping required.Test coverage implication
api.spec.tsxtestsClient.clear()aborting active requests. The parity spec (fetchParity.spec.tsx) intentionally does not cover this becausesentryCellFetchhas no equivalent — the responsibility belongs to TanStack Query, not the fetch function.Error Handler Registration
api.requestPromise—initApiClientErrorHandling()Pushes a single handler into
globalErrorHandlersarray. The handler covers:sso-required→window.location.assign(loginUrl)member-disabled-over-limit→ SPA navigate toextra.nextsession_expiredcookie, reload or navigate to/auth/login/Handlers return
boolean—trueto suppress the per-request error callback.sentryCellFetch—configureSentryCellFetch()+createDefaultErrorHandlers()Error handlers are injected via config, with named hooks:
onSudoRequired→ opens sudo modal, returnsPromise<ApiResponse>(owns the retry)onProjectRenamed→ callsredirectToProject(slug), returnstrueonAuthError→ same logic as the old handler (anon pages, SSO, member limit, session expired)onError→ generic catch-allKey structural difference: sudo/superuser handling lives inside
handleRequestErrorin the old client (part ofClientclass), but is a pluggableonSudoRequiredhandler insentryCellFetch.