Skip to content

Commit b7dc77f

Browse files
skearnesclaude
andauthored
Extract a fetchJson helper for throw-on-error fetches (#193)
The data-fetching hooks/components repeated the same "await fetch, check response.ok, otherwise throw `<label> failed (HTTP <status>)`, then parse JSON" boilerplate. Extract it into `fetchJson<T>` in utils/api.ts and use it at the five sites that share those exact semantics: - ChartView: compound_svg POST and the chart-data GET (the AbortController signal is passed straight through; AbortError still propagates). - MainDatasetView: dataset-metadata react-query queryFn. - useSearchTask: the submit_query call. - MainSelectedSet: the /reactions POST. Sites that deliberately *swallow* errors are intentionally left alone: MainReactionView, CompoundView, ReactionCard, and ModalKetcher return null/'' (or warn) on a bad response so an HTML error body never reaches dangerouslySetInnerHTML, and useSearchTask's poll branches on specific 200/202 status codes. Those don't fit a throw-on-error helper. No behavior change at the migrated sites; `npm run build` (tsc + vite) and `npm run lint` pass. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2e67b0c commit b7dc77f

5 files changed

Lines changed: 81 additions & 47 deletions

File tree

app/src/hooks/useSearchTask.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useRef } from 'react';
1818
import { useQuery } from '@tanstack/react-query';
1919
import reaction_pb from 'ord-schema';
2020
import { base64ToBytes } from '../utils/base64';
21+
import { fetchJson } from '../utils/api';
2122
import type { SearchResult } from '../types/search';
2223

2324
const POLL_INTERVAL_MS = 1000;
@@ -85,13 +86,11 @@ export function useSearchTask(queryString: string | null, enabled: boolean) {
8586
if (taskRef.current.taskId === null) {
8687
if (!taskRef.current.submitPromise) {
8788
taskRef.current.startTime = Date.now();
88-
taskRef.current.submitPromise = (async () => {
89-
const submitRes = await fetch(`/api/submit_query${queryString}`);
90-
if (!submitRes.ok) {
91-
throw new Error(`submit_query failed (HTTP ${submitRes.status})`);
92-
}
93-
return (await submitRes.json()) as string;
94-
})();
89+
taskRef.current.submitPromise = fetchJson<string>(
90+
`/api/submit_query${queryString}`,
91+
undefined,
92+
'submit_query',
93+
);
9594
}
9695
try {
9796
taskRef.current.taskId = await taskRef.current.submitPromise;

app/src/utils/api.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Copyright 2026 Open Reaction Database Project Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Fetches JSON from the API, throwing on a non-2xx response.
19+
*
20+
* Centralizes the "check response.ok, otherwise throw with the HTTP status"
21+
* boilerplate shared by the data-fetching hooks and components. The thrown
22+
* Error message is `${label} failed (HTTP ${status})`, where `label` defaults
23+
* to the request URL.
24+
*
25+
* Note: callers that intentionally *swallow* fetch errors — e.g. to keep an
26+
* HTML error body out of `dangerouslySetInnerHTML` — keep their own inline
27+
* `response.ok` handling and do not use this helper.
28+
*/
29+
export async function fetchJson<T>(
30+
input: RequestInfo | URL,
31+
init?: RequestInit,
32+
label?: string,
33+
): Promise<T> {
34+
const response = await fetch(input, init);
35+
if (!response.ok) {
36+
const name = label ?? (typeof input === 'string' ? input : 'request');
37+
throw new Error(`${name} failed (HTTP ${response.status})`);
38+
}
39+
return (await response.json()) as T;
40+
}

app/src/views/browse/selected-set/MainSelectedSet.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import ReactionCard from '../../../components/ReactionCard';
2222
import DownloadResults from '../../../components/DownloadResults';
2323
import CopyButton from '../../../components/CopyButton';
2424
import { base64ToBytes } from '../../../utils/base64';
25+
import { fetchJson } from '../../../utils/api';
2526
import type { SearchResult } from '../../../types/search';
2627
import './MainSelectedSet.scss';
2728

@@ -38,14 +39,15 @@ const MainSelectedSet: React.FC = () => {
3839
const getSelectedReactions = useCallback(async () => {
3940
setLoading(true);
4041
try {
41-
const response = await fetch('/api/reactions', {
42-
method: 'POST',
43-
headers: { 'Content-Type': 'application/json' },
44-
body: JSON.stringify({ reaction_ids: reactionIds }),
45-
});
46-
47-
if (!response.ok) throw new Error('Failed to fetch reactions');
48-
const fetched = (await response.json()) as Array<Omit<SearchResult, 'data'>>;
42+
const fetched = await fetchJson<Array<Omit<SearchResult, 'data'>>>(
43+
'/api/reactions',
44+
{
45+
method: 'POST',
46+
headers: { 'Content-Type': 'application/json' },
47+
body: JSON.stringify({ reaction_ids: reactionIds }),
48+
},
49+
'reactions',
50+
);
4951

5052
const decoded: SearchResult[] = fetched.map(r => ({
5153
...r,

app/src/views/dataset-view/ChartView.tsx

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
1818
import * as d3 from 'd3';
1919
import LoadingSpinner from '../../components/LoadingSpinner';
2020
import reaction_pb from 'ord-schema';
21+
import { fetchJson } from '../../utils/api';
2122
import './ChartView.scss';
2223

2324
interface ChartData {
@@ -63,20 +64,19 @@ const ChartView: React.FC<ChartViewProps> = ({
6364

6465
const binary = compound.serializeBinary();
6566

66-
const response = await fetch('/api/compound_svg', {
67-
method: 'POST',
68-
headers: {
69-
'Content-Type': 'application/x-protobuf',
67+
// Throw on non-2xx (via fetchJson) so the caller's .catch sets molHtml to
68+
// null instead of feeding an HTML error page to dangerouslySetInnerHTML.
69+
return fetchJson<string>(
70+
'/api/compound_svg',
71+
{
72+
method: 'POST',
73+
headers: {
74+
'Content-Type': 'application/x-protobuf',
75+
},
76+
body: binary as BodyInit,
7077
},
71-
body: binary as BodyInit,
72-
});
73-
74-
// Throw on non-2xx so the caller's .catch sets molHtml to null instead
75-
// of feeding an HTML error page to dangerouslySetInnerHTML.
76-
if (!response.ok) {
77-
throw new Error(`compound_svg failed (HTTP ${response.status})`);
78-
}
79-
return response.json();
78+
'compound_svg',
79+
);
8080
}, []);
8181

8282
const createChart = useCallback(
@@ -213,17 +213,12 @@ const ChartView: React.FC<ChartViewProps> = ({
213213
const controller = new AbortController();
214214
setLoading(true);
215215
setFetchError(null);
216-
fetch(`/api/${apiCall}?dataset_id=${encodeURIComponent(datasetId)}`, {
217-
method: 'GET',
218-
signal: controller.signal,
219-
})
220-
.then(response => {
221-
if (!response.ok) {
222-
throw new Error(`${apiCall} failed (HTTP ${response.status})`);
223-
}
224-
return response.json();
225-
})
226-
.then((data: ChartData[]) => {
216+
fetchJson<ChartData[]>(
217+
`/api/${apiCall}?dataset_id=${encodeURIComponent(datasetId)}`,
218+
{ method: 'GET', signal: controller.signal },
219+
apiCall,
220+
)
221+
.then(data => {
227222
setLoading(false);
228223
setInputsData(data);
229224
})

app/src/views/dataset-view/MainDatasetView.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import LoadingSpinner from '../../components/LoadingSpinner';
2121
import ChartView from './ChartView';
2222
import SearchResults from './SearchResults';
2323
import { useSearchTask } from '../../hooks/useSearchTask';
24+
import { fetchJson } from '../../utils/api';
2425
import type { Dataset } from '../../types/search';
2526
import './MainDatasetView.scss';
2627

@@ -40,15 +41,12 @@ const MainDatasetView: React.FC = () => {
4041
const { data: datasetData, error: datasetError } = useQuery<Dataset>({
4142
queryKey: ['dataset-metadata', datasetId],
4243
enabled: !!datasetId,
43-
queryFn: async () => {
44-
const res = await fetch(
44+
queryFn: () =>
45+
fetchJson<Dataset>(
4546
`/api/dataset?dataset_id=${encodeURIComponent(datasetId!)}`,
46-
);
47-
if (!res.ok) {
48-
throw new Error(`Failed to load dataset metadata (HTTP ${res.status})`);
49-
}
50-
return (await res.json()) as Dataset;
51-
},
47+
undefined,
48+
'dataset metadata',
49+
),
5250
});
5351

5452
return (

0 commit comments

Comments
 (0)