react-native-timacare 3.3.37 → 3.3.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,42 @@ import { load } from '../../utils/storage';
9
9
  import { getSDKConfig, SDKConfig } from '../../sdkConfig';
10
10
  import { Alert } from 'react-native';
11
11
 
12
+ /**
13
+ * Retry tuning for weak / unstable networks.
14
+ * Only transient failures on idempotent requests are retried.
15
+ */
16
+ const MAX_RETRIES = 3;
17
+ const RETRY_BASE_DELAY = 1000; // ms, grows exponentially: 1s, 2s, 4s (+ jitter)
18
+
19
+ /**
20
+ * Default timeout for idempotent GET requests. Kept short so reads fail fast
21
+ * on a weak network and let the retry interceptor take over, instead of
22
+ * hanging for the much longer upload/write timeout. Overridable via
23
+ * SDKConfig.getTimeout.
24
+ */
25
+ const DEFAULT_GET_TIMEOUT = 30000; // 30s
26
+
27
+ /**
28
+ * A failure is transient (worth retrying) when there is no response
29
+ * (timeout / connection drop) or the server is overloaded (5xx / 429).
30
+ */
31
+ const isRetryableError = (error: any): boolean => {
32
+ if (!error || error.config?.__skipRetry) return false;
33
+ // No response object => network error or timeout (ECONNABORTED)
34
+ if (!error.response) return true;
35
+ const status = error.response.status;
36
+ return status >= 500 || status === 429;
37
+ };
38
+
39
+ /**
40
+ * GET/HEAD/OPTIONS are safe to replay. POST/PUT/etc. are NOT retried
41
+ * automatically to avoid duplicate side effects (double loan submit, double OTP).
42
+ */
43
+ const isIdempotent = (method?: string): boolean => {
44
+ const m = (method || 'get').toLowerCase();
45
+ return m === 'get' || m === 'head' || m === 'options';
46
+ };
47
+
12
48
  /**
13
49
  * Manages all requests to the API.
14
50
  */
@@ -25,6 +61,16 @@ export class Api {
25
61
  */
26
62
  private isLoggingOut: boolean = false;
27
63
 
64
+ /**
65
+ * Last known network connectivity, driven by the injected NetInfo module.
66
+ * Defaults to true (optimistic) so requests are never blocked when NetInfo
67
+ * is not provided or its first event hasn't arrived yet.
68
+ */
69
+ private isConnected: boolean = true;
70
+
71
+ /** Unsubscribe handle for the NetInfo listener, cleared on re-setup. */
72
+ private netInfoUnsubscribe?: () => void;
73
+
28
74
  /**
29
75
  * Configurable options.
30
76
  */
@@ -136,6 +182,133 @@ export class Api {
136
182
 
137
183
  this.apisauce.addMonitor(unauthorizedMonitor);
138
184
  this.apisauceFormData.addMonitor(unauthorizedMonitor);
185
+
186
+ // Subscribe to connectivity changes (no-op if NetInfo isn't injected).
187
+ this.subscribeNetInfo();
188
+
189
+ // Request-side guards: fast-fail when offline + shorten GET timeouts.
190
+ // GET-timeout splitting only applies to the main instance; the form-data
191
+ // instance is used for uploads which legitimately need the long timeout.
192
+ this.attachRequestGuards(this.apisauce, true);
193
+ this.attachRequestGuards(this.apisauceFormData, false);
194
+
195
+ // Auto-retry transient failures on weak networks (timeouts, dropped
196
+ // connections, 5xx, 429). Applied to the underlying axios instance so
197
+ // apisauce still sees the final (successful or exhausted) response.
198
+ this.attachRetryInterceptor(this.apisauce);
199
+ this.attachRetryInterceptor(this.apisauceFormData);
200
+ }
201
+
202
+ /**
203
+ * Subscribes to the injected NetInfo module (if any) to track connectivity.
204
+ * Safe to call repeatedly — the previous listener is torn down first.
205
+ */
206
+ private subscribeNetInfo() {
207
+ if (this.netInfoUnsubscribe) {
208
+ try {
209
+ this.netInfoUnsubscribe();
210
+ } catch (e) {
211
+ myLog('[API] NetInfo unsubscribe error:', e);
212
+ }
213
+ this.netInfoUnsubscribe = undefined;
214
+ }
215
+
216
+ const netInfo = this.config.netInfo;
217
+ if (!netInfo) {
218
+ this.isConnected = true; // no NetInfo => never block on connectivity
219
+ return;
220
+ }
221
+
222
+ // Seed current state, then listen for changes.
223
+ netInfo
224
+ .fetch()
225
+ .then(state => {
226
+ this.isConnected = state.isConnected !== false;
227
+ })
228
+ .catch(() => {
229
+ this.isConnected = true;
230
+ });
231
+
232
+ this.netInfoUnsubscribe = netInfo.addEventListener(state => {
233
+ this.isConnected = state.isConnected !== false;
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Attaches a request interceptor that:
239
+ * 1. Fast-fails when the device is known to be offline (avoids waiting for
240
+ * a long timeout before the user sees an error).
241
+ * 2. Shortens the timeout for idempotent GETs on the main instance so reads
242
+ * fail fast and hand off to the retry interceptor.
243
+ */
244
+ private attachRequestGuards(instance: ApisauceInstance, splitGetTimeout: boolean) {
245
+ const getTimeout = this.config.getTimeout || DEFAULT_GET_TIMEOUT;
246
+
247
+ instance.axiosInstance.interceptors.request.use((config: any) => {
248
+ // Offline fast-fail. apisauce converts this rejection into a normal
249
+ // CANNOT_CONNECT / NETWORK_ERROR problem, same as a timeout but instant.
250
+ if (this.config.netInfo && this.isConnected === false) {
251
+ myLog(`[API] Offline - skipping request: ${config.url}`);
252
+ return Promise.reject({
253
+ config,
254
+ message: 'Network offline',
255
+ __offline: true,
256
+ });
257
+ }
258
+
259
+ // Shorten GET timeouts unless the caller passed an explicit override.
260
+ if (
261
+ splitGetTimeout &&
262
+ isIdempotent(config.method) &&
263
+ config.timeout === this.config.timeout
264
+ ) {
265
+ config.timeout = getTimeout;
266
+ }
267
+
268
+ return config;
269
+ });
270
+ }
271
+
272
+ /**
273
+ * Attaches an exponential-backoff retry interceptor to an apisauce
274
+ * instance's axios layer. Only idempotent requests with transient
275
+ * errors are replayed, up to MAX_RETRIES times.
276
+ */
277
+ private attachRetryInterceptor(instance: ApisauceInstance) {
278
+ instance.axiosInstance.interceptors.response.use(
279
+ response => response,
280
+ async (error: any) => {
281
+ const config = error?.config;
282
+
283
+ if (
284
+ !config ||
285
+ !isIdempotent(config.method) ||
286
+ !isRetryableError(error)
287
+ ) {
288
+ return Promise.reject(error);
289
+ }
290
+
291
+ config.__retryCount = config.__retryCount || 0;
292
+ if (config.__retryCount >= MAX_RETRIES) {
293
+ myLog(`[API] Retry exhausted (${MAX_RETRIES}) for ${config.url}`);
294
+ return Promise.reject(error);
295
+ }
296
+
297
+ config.__retryCount += 1;
298
+
299
+ // Exponential backoff with jitter: 1s, 2s, 4s (+ up to 300ms)
300
+ const delay =
301
+ RETRY_BASE_DELAY * Math.pow(2, config.__retryCount - 1) +
302
+ Math.floor(Math.random() * 300);
303
+
304
+ myLog(
305
+ `[API] Retry ${config.__retryCount}/${MAX_RETRIES} in ${delay}ms: ${config.url}`
306
+ );
307
+
308
+ await new Promise(resolve => setTimeout(resolve, delay));
309
+ return instance.axiosInstance(config);
310
+ }
311
+ );
139
312
  }
140
313
 
141
314
  /**