Skip to content

Commit dc6078c

Browse files
authored
Merge pull request #3536 from ecency/fix/network-request-deadlines
fix(network): bound every request and surface the failure
2 parents 7f09011 + 825d8f1 commit dc6078c

38 files changed

Lines changed: 1521 additions & 33 deletions

android/app/src/main/java/app/esteem/mobile/android/MainApplication.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import com.facebook.react.ReactPackage
99
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
1010
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
1111
import com.facebook.react.defaults.DefaultReactNativeHost
12+
import com.facebook.react.modules.network.OkHttpClientFactory
13+
import com.facebook.react.modules.network.OkHttpClientProvider
1214
import com.facebook.react.soloader.OpenSourceMergedSoMapping
1315
import com.facebook.soloader.SoLoader
16+
import java.util.concurrent.TimeUnit
1417

1518
//expo related packages
1619
import android.content.Context
@@ -49,6 +52,39 @@ class MainApplication : Application(), ReactApplication {
4952
override fun onCreate() {
5053
super.onCreate()
5154

55+
// React Native builds its shared OkHttpClient with connect, read and write
56+
// timeouts of 0, which OkHttp reads as "wait forever"
57+
// (OkHttpClientProvider.createClientBuilder). A socket that is accepted and
58+
// then goes silent therefore never produces an error, and the JS promise
59+
// behind it never settles.
60+
//
61+
// connectTimeout is the one that changes behaviour rather than just adding a
62+
// ceiling: OkHttp tries a host's addresses one route at a time, each with its
63+
// own connect timeout, so with 0 a single black-holed address hangs the call
64+
// forever and the remaining addresses are never tried. 10s is well above a
65+
// real handshake even on a poor link, and low enough that a dead route falls
66+
// over to the next one inside the JS deadline (utils/networkTimeout).
67+
//
68+
// read and write are deliberately left alone. They are idle timeouts applied
69+
// to every request on the shared client, so any value low enough to be a
70+
// useful backstop is also low enough to cut short a request that asked for
71+
// longer: a server that accepts an order and then works on it silently would
72+
// be aborted mid-flight, which is the unknown-outcome case the wider
73+
// purchase deadline exists to avoid. The per-request deadline belongs on
74+
// callTimeout, which React Native sets per request from the JS-side timeout
75+
// (NetworkingModule), and every JS caller now carries one.
76+
//
77+
// Must be set before the first client is created, which happens when
78+
// NetworkingModule is built.
79+
OkHttpClientProvider.setOkHttpClientFactory(
80+
OkHttpClientFactory {
81+
// createClientBuilder(context) keeps React Native's own cookie jar
82+
// and its 10MB response cache; only the timeouts change.
83+
OkHttpClientProvider.createClientBuilder(this)
84+
.connectTimeout(10, TimeUnit.SECONDS)
85+
.build()
86+
}
87+
)
5288

5389
SoLoader.init(this, OpenSourceMergedSoMapping)
5490

index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import 'intl';
66
import 'intl/locale-data/jsonp/en-US';
77
import 'react-native-get-random-values';
88
import './src/utils/abortSignalPolyfill';
9+
// Bounds every HTTP(S) fetch. Must run before ./App, which pulls in @ecency/sdk:
10+
// the SDK binds globalThis.fetch on first use and caches the bound reference.
11+
import './src/utils/installFetchDeadline';
912

1013
import EcencyApp from './App';
1114

src/components/basicUIElements/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import WalletLineItem from './view/walletLineItem/walletLineItemView';
1212
import CommunityListItem from './view/communityListItem/communityListItem';
1313
import Separator from './view/separator/separatorView';
1414
import EmptyScreen from './view/emptyScreen/emptyScreenView';
15+
import QueryErrorRetry from './view/queryErrorRetry/queryErrorRetryView';
1516

1617
// // Placeholders
1718
import ListItemPlaceHolder from './view/placeHolder/listItemPlaceHolderView';
@@ -52,4 +53,5 @@ export {
5253
CommunitiesPlaceHolder,
5354
Separator,
5455
EmptyScreen,
56+
QueryErrorRetry,
5557
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import EStyleSheet from 'react-native-extended-stylesheet';
2+
3+
export default EStyleSheet.create({
4+
container: {
5+
alignItems: 'center',
6+
justifyContent: 'center',
7+
paddingHorizontal: 32,
8+
paddingVertical: 48,
9+
},
10+
containerCompact: {
11+
alignItems: 'center',
12+
justifyContent: 'center',
13+
paddingHorizontal: 16,
14+
paddingVertical: 20,
15+
},
16+
icon: {
17+
color: '$iconColor',
18+
marginBottom: 8,
19+
},
20+
message: {
21+
color: '$primaryDarkText',
22+
fontFamily: '$primaryFont',
23+
fontSize: 14,
24+
marginBottom: 16,
25+
textAlign: 'center',
26+
},
27+
messageCompact: {
28+
color: '$primaryDarkGray',
29+
fontFamily: '$primaryFont',
30+
fontSize: 13,
31+
marginBottom: 12,
32+
textAlign: 'center',
33+
},
34+
button: {
35+
alignItems: 'center',
36+
backgroundColor: '$primaryBlue',
37+
borderRadius: 20,
38+
height: 40,
39+
justifyContent: 'center',
40+
paddingHorizontal: 24,
41+
},
42+
buttonDisabled: {
43+
opacity: 0.6,
44+
},
45+
buttonText: {
46+
color: '$pureWhite',
47+
fontFamily: '$primaryFont',
48+
fontSize: 13,
49+
fontWeight: '600',
50+
},
51+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React from 'react';
2+
import TestRenderer from 'react-test-renderer';
3+
import { Text, TouchableOpacity } from 'react-native';
4+
5+
jest.mock('react-native-extended-stylesheet', () => ({
6+
create: (styles: any) => styles,
7+
value: jest.fn(() => '#000000'),
8+
}));
9+
10+
// Icon pulls in react-native-vector-icons' native font loading.
11+
jest.mock('../../../icon', () => ({ Icon: 'Icon' }));
12+
13+
jest.mock('react-intl', () => ({
14+
useIntl: () => ({ formatMessage: ({ id }: { id: string }) => id }),
15+
}));
16+
17+
// eslint-disable-next-line import/first
18+
import QueryErrorRetry from './queryErrorRetryView';
19+
20+
const render = (props: React.ComponentProps<typeof QueryErrorRetry>) => {
21+
let tree!: TestRenderer.ReactTestRenderer;
22+
TestRenderer.act(() => {
23+
tree = TestRenderer.create(<QueryErrorRetry {...props} />);
24+
});
25+
return tree;
26+
};
27+
28+
const messages = (tree: TestRenderer.ReactTestRenderer) =>
29+
tree.root.findAllByType(Text as any).map((node) => node.props.children);
30+
31+
describe('QueryErrorRetry', () => {
32+
it('tells a timeout apart from any other failure', () => {
33+
const timeout = Object.assign(new Error('Request timed out'), { name: 'TimeoutError' });
34+
35+
expect(messages(render({ error: timeout, onRetry: jest.fn() }))).toContain(
36+
'alert.request_timed_out',
37+
);
38+
expect(messages(render({ error: new Error('boom'), onRetry: jest.fn() }))).toContain(
39+
'alert.load_failed_retry',
40+
);
41+
});
42+
43+
it('says it is retrying while the retry is in flight, and disables the button', () => {
44+
const tree = render({ onRetry: jest.fn(), isRetrying: true });
45+
46+
expect(messages(tree)).toContain('alert.retrying');
47+
expect(tree.root.findByType(TouchableOpacity as any).props.disabled).toBe(true);
48+
});
49+
50+
it('calls onRetry with no arguments', () => {
51+
// Load-bearing: `refetch` is passed straight in at some call sites, and
52+
// React Query reads its first argument as options. Handing it the press
53+
// event would be interpreted as a refetch configuration object.
54+
const onRetry = jest.fn();
55+
const tree = render({ onRetry });
56+
57+
TestRenderer.act(() => {
58+
tree.root.findByType(TouchableOpacity as any).props.onPress({ nativeEvent: {} });
59+
});
60+
61+
expect(onRetry).toHaveBeenCalledTimes(1);
62+
expect(onRetry).toHaveBeenCalledWith();
63+
});
64+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React from 'react';
2+
import { Text, TouchableOpacity, View } from 'react-native';
3+
import { useIntl } from 'react-intl';
4+
5+
import { Icon } from '../../../icon';
6+
import styles from './queryErrorRetryStyles';
7+
8+
interface Props {
9+
/** The query error, used only to pick between the two messages. */
10+
error?: unknown;
11+
onRetry: () => void;
12+
/** True while the retry is in flight, so the button reads as busy. */
13+
isRetrying?: boolean;
14+
/** Inline variant for a card or a list header rather than a full empty state. */
15+
compact?: boolean;
16+
}
17+
18+
/**
19+
* Terminal state for a query that failed: says what happened and offers the one
20+
* action that can fix it. Every list or card that can show a loading skeleton
21+
* needs one of these, otherwise a request that never answers reads as a screen
22+
* that is still working.
23+
*
24+
* `TimeoutError` is set by the global fetch deadline (utils/networkTimeout) and
25+
* by the ecencyApi response interceptor, and it earns a different message: the
26+
* server said nothing at all, which points at the connection rather than at us.
27+
*/
28+
const QueryErrorRetry = ({ error, onRetry, isRetrying, compact }: Props) => {
29+
const intl = useIntl();
30+
31+
const isTimeout = (error as { name?: string })?.name === 'TimeoutError';
32+
33+
return (
34+
<View style={compact ? styles.containerCompact : styles.container}>
35+
<Icon
36+
iconType="MaterialIcons"
37+
name={isTimeout ? 'cloud-off' : 'error-outline'}
38+
size={compact ? 18 : 28}
39+
style={styles.icon}
40+
/>
41+
<Text style={compact ? styles.messageCompact : styles.message}>
42+
{intl.formatMessage({
43+
id: isTimeout ? 'alert.request_timed_out' : 'alert.load_failed_retry',
44+
})}
45+
</Text>
46+
<TouchableOpacity
47+
style={[styles.button, isRetrying && styles.buttonDisabled]}
48+
onPress={() => onRetry()}
49+
disabled={isRetrying}
50+
accessibilityRole="button"
51+
>
52+
<Text style={styles.buttonText}>
53+
{intl.formatMessage({
54+
id: isRetrying ? 'alert.retrying' : 'alert.something_wrong_reload',
55+
})}
56+
</Text>
57+
</TouchableOpacity>
58+
</View>
59+
);
60+
};
61+
62+
export default QueryErrorRetry;

src/components/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ import {
148148
PostCardPlaceHolder,
149149
PostPlaceHolder,
150150
ProfileSummaryPlaceHolder,
151+
QueryErrorRetry,
151152
StickyBar,
152153
Tag,
153154
TextWithIcon,
@@ -226,6 +227,7 @@ export {
226227
ProfileSummary,
227228
ProfileSummaryPlaceHolder,
228229
Promote,
230+
QueryErrorRetry,
229231
PulseAnimation,
230232
ScaleSlider,
231233
SearchInput,

src/components/notification/view/notificationView.tsx

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ActivityIndicator, FlatList, Text, View, RefreshControl } from 'react-n
66
// Components
77
import EStyleSheet from 'react-native-extended-stylesheet';
88
import { NotificationLine } from '../..';
9-
import { ListPlaceHolder } from '../../basicUIElements';
9+
import { ListPlaceHolder, QueryErrorRetry } from '../../basicUIElements';
1010
import { FilterBar } from '../../filterBar';
1111

1212
// Styles
@@ -42,6 +42,9 @@ interface Props {
4242
notifications: any[];
4343
isLoading: boolean;
4444
isFetching: boolean;
45+
/** The list failed and there is nothing cached to show instead. */
46+
isError?: boolean;
47+
error?: unknown;
4548
isNotificationRefreshing: boolean;
4649
globalProps: any;
4750
handleOnUserPress: (username?: string) => void;
@@ -56,6 +59,8 @@ const NotificationView = ({
5659
notifications,
5760
isLoading,
5861
isFetching,
62+
isError,
63+
error,
5964
isNotificationRefreshing,
6065
globalProps,
6166
handleOnUserPress,
@@ -109,6 +114,32 @@ const NotificationView = ({
109114
return null;
110115
};
111116

117+
// Order matters: the failure is checked before the loading skeleton, because
118+
// a query that failed is still `isFetching` for the moment React Query spends
119+
// settling it, and before the "no activity" copy, which would otherwise claim
120+
// an empty inbox on a request that never arrived.
121+
const _renderEmptyComponent = () => {
122+
if (isError) {
123+
return (
124+
<QueryErrorRetry
125+
error={error}
126+
onRetry={() => getActivities()}
127+
isRetrying={isNotificationRefreshing}
128+
/>
129+
);
130+
}
131+
132+
if (isLoading || isFetching || isNotificationRefreshing) {
133+
return <ListPlaceHolder />;
134+
}
135+
136+
return (
137+
<Text style={globalStyles.hintText}>
138+
{intl.formatMessage({ id: 'notification.noactivity' })}
139+
</Text>
140+
);
141+
};
142+
112143
const _renderItem = ({ item }: any) => (
113144
<NotificationLine
114145
notification={item}
@@ -141,15 +172,7 @@ const NotificationView = ({
141172
onEndReachedThreshold={0.3}
142173
onMomentumScrollBegin={_handleMomentumScrollBegin}
143174
ListFooterComponent={_renderFooterLoading}
144-
ListEmptyComponent={
145-
isLoading || isFetching || isNotificationRefreshing ? (
146-
<ListPlaceHolder />
147-
) : (
148-
<Text style={globalStyles.hintText}>
149-
{intl.formatMessage({ id: 'notification.noactivity' })}
150-
</Text>
151-
)
152-
}
175+
ListEmptyComponent={_renderEmptyComponent}
153176
contentContainerStyle={styles.listContentContainer}
154177
refreshControl={
155178
<RefreshControl

src/components/tabbedPosts/view/listEmptyView.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
useCommunitySubscriptionAction,
1313
useFollowUserAction,
1414
} from '../../../hooks';
15-
import { NoPost, PostCardPlaceHolder, UserListItem } from '../..';
15+
import { NoPost, PostCardPlaceHolder, QueryErrorRetry, UserListItem } from '../..';
1616
import globalStyles from '../../../globalStyles';
1717
import { CommunityListItem, EmptyScreen } from '../../basicUIElements';
1818
import styles from '../styles/tabbedPosts.styles';
@@ -29,9 +29,23 @@ import {
2929
interface TabEmptyViewProps {
3030
filterKey: string;
3131
isNoPost: boolean;
32+
/** The first page failed and there is nothing cached to show instead. */
33+
isError?: boolean;
34+
error?: unknown;
35+
isRetrying?: boolean;
36+
onRetry?: () => void;
3237
}
3338

34-
const TabEmptyView = ({ filterKey, isNoPost }: TabEmptyViewProps) => {
39+
const TabEmptyView = ({
40+
filterKey,
41+
isNoPost,
42+
isError,
43+
// Renamed on the way in: this component already destructures an `error` out of
44+
// the leaderboard and communities redux slices further down.
45+
error: loadError,
46+
isRetrying,
47+
onRetry,
48+
}: TabEmptyViewProps) => {
3549
const intl = useIntl();
3650
const dispatch = useDispatch();
3751
const navigation = useNavigation();
@@ -302,6 +316,14 @@ const TabEmptyView = ({ filterKey, isNoPost }: TabEmptyViewProps) => {
302316
}
303317
}
304318

319+
// Checked after the logged-out and empty-feed branches, both of which are
320+
// real answers rather than failures, and before the placeholder: the
321+
// placeholder is the fallthrough for "still loading", so without this a feed
322+
// whose first page failed keeps a skeleton on screen with no way forward.
323+
if (isError && onRetry) {
324+
return <QueryErrorRetry error={loadError} onRetry={onRetry} isRetrying={isRetrying} />;
325+
}
326+
305327
return (
306328
<View style={styles.placeholderWrapper}>
307329
<PostCardPlaceHolder />

0 commit comments

Comments
 (0)