378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
import { AppState, Platform } from "react-native";
|
|
import * as Sentry from "@sentry/react-native";
|
|
import { DATA_ENDPOINT_CONFIG } from "./endpoints";
|
|
import { ObservedFetchOptions } from "./types";
|
|
import { ObservedFetchError, shouldRetryNetworkError } from "./networkError";
|
|
import { reportDataFetchFailure, reportDataFetchSuccess, truncateResponseHead } from "./sentryNetwork";
|
|
import { lastObservedRootRouteRef, rootNavigationRef } from "@/lib/rootNavigation";
|
|
|
|
type ParsedUrl = { host: string; pathTemplate: string };
|
|
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const getRootTab = (fallback?: string) => {
|
|
if (fallback) return fallback;
|
|
try {
|
|
const state = rootNavigationRef.getState?.();
|
|
const routeName = state?.routes?.[state.index ?? 0]?.name;
|
|
if (routeName) {
|
|
lastObservedRootRouteRef.current = routeName;
|
|
return routeName;
|
|
}
|
|
} catch {}
|
|
return lastObservedRootRouteRef.current ?? "unknown";
|
|
};
|
|
|
|
const getKnownOnline = () => {
|
|
const online = (globalThis as any)?.navigator?.onLine;
|
|
return typeof online === "boolean" ? online : true;
|
|
};
|
|
|
|
const getRuntimeState = () => ({
|
|
appState: AppState.currentState,
|
|
online: getKnownOnline(),
|
|
});
|
|
|
|
const canStartFetch = (options: ObservedFetchOptions) => {
|
|
const runtime = getRuntimeState();
|
|
return (options.allowBackground || runtime.appState !== "background") && runtime.online !== false;
|
|
};
|
|
|
|
const shouldCaptureRuntimeFailure = () => {
|
|
const runtime = getRuntimeState();
|
|
return runtime.appState !== "background" && runtime.online !== false;
|
|
};
|
|
|
|
const getOptionTags = (options: ObservedFetchOptions) => ({
|
|
source: options.source ?? "rn_fetch",
|
|
user_visible: String(options.userVisible ?? false),
|
|
preload: String(options.preload ?? false),
|
|
fetch_priority: options.fetchPriority ?? "medium",
|
|
});
|
|
|
|
const safeParseUrl = (url: string, urlPathTemplate?: string): ParsedUrl => {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return {
|
|
host: parsed.host,
|
|
pathTemplate: urlPathTemplate ?? parsed.pathname,
|
|
};
|
|
} catch {
|
|
return { host: "unknown", pathTemplate: urlPathTemplate ?? "unknown" };
|
|
}
|
|
};
|
|
|
|
const estimateBytes = (text: string) => {
|
|
if (typeof Blob !== "undefined") {
|
|
return new Blob([text]).size;
|
|
}
|
|
return text.length;
|
|
};
|
|
|
|
const looksJson = (contentType: string, text: string) => {
|
|
const trimmed = text.trimStart();
|
|
return contentType.includes("application/json") || trimmed.startsWith("{") || trimmed.startsWith("[");
|
|
};
|
|
|
|
const getFetchSignal = (controller: AbortController, externalSignal?: AbortSignal) => {
|
|
if (!externalSignal) return controller.signal;
|
|
if (externalSignal.aborted) controller.abort();
|
|
const abort = () => controller.abort();
|
|
externalSignal.addEventListener("abort", abort, { once: true });
|
|
return controller.signal;
|
|
};
|
|
|
|
const buildContext = (args: {
|
|
endpoint: string;
|
|
status?: number;
|
|
ok?: boolean;
|
|
durationMs: number;
|
|
timeoutMs: number;
|
|
bytes?: number;
|
|
contentType?: string;
|
|
responseHead?: string;
|
|
urlHost: string;
|
|
urlPathTemplate: string;
|
|
retryCount: number;
|
|
}) => ({
|
|
endpoint: args.endpoint,
|
|
method: "GET",
|
|
status: args.status,
|
|
ok: args.ok,
|
|
durationMs: args.durationMs,
|
|
timeoutMs: args.timeoutMs,
|
|
bytes: args.bytes,
|
|
contentType: args.contentType,
|
|
responseHead: truncateResponseHead(args.responseHead),
|
|
retryCount: args.retryCount,
|
|
urlHost: args.urlHost,
|
|
urlPathTemplate: args.urlPathTemplate,
|
|
...getRuntimeState(),
|
|
});
|
|
|
|
async function observedFetchTextOnce(url: string, options: ObservedFetchOptions, reportSuccess = true): Promise<{ text: string; response: Response; durationMs: number; bytes: number; contentType: string; context: Record<string, unknown>; slow: boolean; statusTag: string }> {
|
|
const endpoint = options.endpoint ?? "unknown";
|
|
const config = DATA_ENDPOINT_CONFIG[endpoint];
|
|
const timeoutMs = options.timeoutMs ?? config.timeoutMs;
|
|
const slowMs = options.slowMs ?? config.slowMs;
|
|
const startedAt = Date.now();
|
|
const parsedUrl = safeParseUrl(url, options.urlPathTemplate);
|
|
const retryCount = options.retryCount ?? 0;
|
|
const method = options.method ?? "GET";
|
|
const rootTab = getRootTab(options.rootTab);
|
|
const baseTags = {
|
|
area: "data_fetch",
|
|
endpoint,
|
|
result: "unknown",
|
|
root_tab: rootTab,
|
|
platform: Platform.OS,
|
|
...getOptionTags(options),
|
|
};
|
|
|
|
if (!canStartFetch(options)) {
|
|
const runtime = getRuntimeState();
|
|
const error = new ObservedFetchError(
|
|
runtime.online === false ? "Data fetch skipped while offline" : "Data fetch skipped while app is background",
|
|
"aborted",
|
|
{ retryable: false }
|
|
);
|
|
reportDataFetchFailure({
|
|
kind: "aborted",
|
|
error,
|
|
tags: { ...baseTags, result: "aborted" },
|
|
context: buildContext({
|
|
endpoint,
|
|
durationMs: 0,
|
|
timeoutMs,
|
|
urlHost: parsedUrl.host,
|
|
urlPathTemplate: parsedUrl.pathTemplate,
|
|
retryCount,
|
|
}),
|
|
capture: false,
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const signal = getFetchSignal(controller, options.signal);
|
|
let timedOut = false;
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
timeoutId = setTimeout(() => {
|
|
timedOut = true;
|
|
controller.abort();
|
|
reject(new Error("Network request timed out"));
|
|
}, timeoutMs);
|
|
});
|
|
|
|
Sentry.addBreadcrumb({
|
|
category: "data_fetch",
|
|
level: "info",
|
|
message: "fetch:start",
|
|
data: { endpoint, method, urlHost: parsedUrl.host, urlPathTemplate: parsedUrl.pathTemplate, timeoutMs, retryCount },
|
|
});
|
|
|
|
const run = async () => {
|
|
try {
|
|
const { endpoint: _endpoint, expectedContentType: _expectedContentType, timeoutMs: _timeoutMs, slowMs: _slowMs, rootTab: _rootTab, source: _source, userVisible: _userVisible, preload: _preload, fetchPriority: _fetchPriority, urlPathTemplate: _urlPathTemplate, retryCount: _retryCount, retry: _retry, allowBackground: _allowBackground, signal: _signal, ...requestOptions } = options;
|
|
const { response, text, contentType } = await Promise.race([
|
|
(async () => {
|
|
const response = await fetch(url, {
|
|
...requestOptions,
|
|
signal,
|
|
});
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
const text = await response.text();
|
|
return { response, text, contentType };
|
|
})(),
|
|
timeoutPromise,
|
|
]);
|
|
const durationMs = Date.now() - startedAt;
|
|
const bytes = estimateBytes(text);
|
|
const context = buildContext({
|
|
endpoint,
|
|
status: response.status,
|
|
ok: response.ok,
|
|
durationMs,
|
|
timeoutMs,
|
|
bytes,
|
|
contentType,
|
|
urlHost: parsedUrl.host,
|
|
urlPathTemplate: parsedUrl.pathTemplate,
|
|
retryCount,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const retryable = [502, 503, 504].includes(response.status);
|
|
const error = new ObservedFetchError("HTTP " + response.status, "http_error", { status: response.status, retryable });
|
|
reportDataFetchFailure({
|
|
kind: "http_error",
|
|
error,
|
|
tags: { ...baseTags, result: "http_error", status: String(response.status) },
|
|
context: { ...context, responseHead: truncateResponseHead(text) },
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
const slow = durationMs >= slowMs;
|
|
if (reportSuccess) {
|
|
reportDataFetchSuccess({
|
|
tags: { ...baseTags, result: slow ? "slow_success" : "success", status: String(response.status) },
|
|
context,
|
|
slow,
|
|
});
|
|
}
|
|
|
|
return { text, response, durationMs, bytes, contentType, context, slow, statusTag: String(response.status) };
|
|
} catch (error) {
|
|
if (error instanceof ObservedFetchError) throw error;
|
|
const durationMs = Date.now() - startedAt;
|
|
const isAbort = timedOut || (error as any)?.name === "AbortError";
|
|
const kind = isAbort ? "timeout" : String((error as any)?.message ?? "").includes("Network request failed") ? "network_error" : "unknown";
|
|
const retryable = kind === "timeout" || kind === "network_error";
|
|
const wrapped = new ObservedFetchError(
|
|
isAbort ? "Network request timed out" : String((error as any)?.message ?? error),
|
|
kind,
|
|
isAbort ? { retryable } : { retryable, cause: error }
|
|
);
|
|
reportDataFetchFailure({
|
|
kind,
|
|
error: wrapped,
|
|
tags: { ...baseTags, result: kind },
|
|
context: buildContext({
|
|
endpoint,
|
|
durationMs,
|
|
timeoutMs,
|
|
urlHost: parsedUrl.host,
|
|
urlPathTemplate: parsedUrl.pathTemplate,
|
|
retryCount,
|
|
}),
|
|
capture: shouldCaptureRuntimeFailure(),
|
|
});
|
|
throw wrapped;
|
|
} finally {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
|
|
const spanOptions = {
|
|
name: "fetch " + endpoint,
|
|
description: "fetch " + endpoint,
|
|
op: "http.client",
|
|
attributes: {
|
|
"app.area": "data_fetch",
|
|
"app.endpoint": endpoint,
|
|
"app.root_tab": rootTab,
|
|
"http.method": method,
|
|
"server.address": parsedUrl.host,
|
|
},
|
|
};
|
|
|
|
if (typeof (Sentry as any).startSpan === "function") {
|
|
return (Sentry as any).startSpan(spanOptions, run);
|
|
}
|
|
|
|
return run();
|
|
}
|
|
|
|
export async function observedFetchText(url: string, options: ObservedFetchOptions): Promise<string> {
|
|
const retryCount = options.retryCount ?? 0;
|
|
try {
|
|
const result = await observedFetchTextOnce(url, { ...options, retryCount });
|
|
return result.text;
|
|
} catch (error) {
|
|
if (!options.retry || retryCount >= 1 || !shouldRetryNetworkError(error)) {
|
|
throw error;
|
|
}
|
|
if (!canStartFetch(options)) {
|
|
throw error;
|
|
}
|
|
await sleep(750 + Math.random() * 500);
|
|
return observedFetchText(url, { ...options, retryCount: retryCount + 1 });
|
|
}
|
|
}
|
|
|
|
export async function observedFetchJson<T>(url: string, options: ObservedFetchOptions): Promise<T> {
|
|
const endpoint = options.endpoint ?? "unknown";
|
|
const config = DATA_ENDPOINT_CONFIG[endpoint];
|
|
const expectedContentType = options.expectedContentType ?? config.expectedContentType;
|
|
const parsedUrl = safeParseUrl(url, options.urlPathTemplate);
|
|
const retryCount = options.retryCount ?? 0;
|
|
const rootTab = getRootTab(options.rootTab);
|
|
|
|
try {
|
|
const result = await observedFetchTextOnce(url, { ...options, retryCount }, false);
|
|
const trimmed = result.text.trim();
|
|
const context = buildContext({
|
|
endpoint,
|
|
status: result.response.status,
|
|
ok: result.response.ok,
|
|
durationMs: result.durationMs,
|
|
timeoutMs: options.timeoutMs ?? config.timeoutMs,
|
|
bytes: result.bytes,
|
|
contentType: result.contentType,
|
|
urlHost: parsedUrl.host,
|
|
urlPathTemplate: parsedUrl.pathTemplate,
|
|
retryCount,
|
|
});
|
|
|
|
if (trimmed.length === 0) {
|
|
const error = new ObservedFetchError("Empty response", "empty_response", { retryable: false });
|
|
reportDataFetchFailure({
|
|
kind: "empty_response",
|
|
error,
|
|
tags: { area: "data_fetch", endpoint, result: "empty_response", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), status: String(result.response.status) },
|
|
context,
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
if (expectedContentType === "json" && !looksJson(result.contentType, result.text)) {
|
|
const error = new ObservedFetchError("Non JSON response", "non_json_response", { retryable: false });
|
|
reportDataFetchFailure({
|
|
kind: "non_json_response",
|
|
error,
|
|
tags: { area: "data_fetch", endpoint, result: "non_json", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), status: String(result.response.status) },
|
|
context: { ...context, responseHead: truncateResponseHead(result.text) },
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(result.text) as T;
|
|
reportDataFetchSuccess({
|
|
tags: {
|
|
area: "data_fetch",
|
|
endpoint,
|
|
result: result.slow ? "slow_success" : "success",
|
|
root_tab: rootTab,
|
|
platform: Platform.OS,
|
|
...getOptionTags(options),
|
|
status: result.statusTag,
|
|
},
|
|
context,
|
|
slow: result.slow,
|
|
});
|
|
return parsed;
|
|
} catch (error) {
|
|
const wrapped = new ObservedFetchError("JSON parse error", "json_parse_error", { retryable: false, cause: error });
|
|
reportDataFetchFailure({
|
|
kind: "json_parse_error",
|
|
error: wrapped,
|
|
tags: { area: "data_fetch", endpoint, result: "parse_error", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), status: String(result.response.status) },
|
|
context: { ...context, responseHead: truncateResponseHead(result.text) },
|
|
});
|
|
throw wrapped;
|
|
}
|
|
} catch (error) {
|
|
if (!options.retry || retryCount >= 1 || !shouldRetryNetworkError(error)) {
|
|
throw error;
|
|
}
|
|
if (!canStartFetch(options)) {
|
|
throw error;
|
|
}
|
|
await sleep(750 + Math.random() * 500);
|
|
return observedFetchJson<T>(url, { ...options, retryCount: retryCount + 1 });
|
|
}
|
|
}
|