All files / client/src/utils networkErrorPolicy.ts

92.42% Statements 61/66
76.92% Branches 20/26
100% Functions 16/16
92.3% Lines 60/65

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210                              521x   521x   521x                 521x                   521x       520x     521x 3276x               521x 521x   521x 9x           521x             521x 75x       521x       521x 521x           521x 3203x 3203x 3203x     42x   3203x 6x           521x           521x 40x 30x 30x   10x 10x       10x   10x 10x     10x     10x 10x       6x     4x     1x 1x       10x 10x               521x 59x 19x 40x 40x               521x 65x       61x     4x               521x   16x   521x 4x           4x       521x         4x                  
import { config } from "shared/config";
import { ApiEndpoint } from "shared/constants/apiEndpoints";
import { BackendError, BackendErrorType } from "client/types/errorTypes";
import { fetchWithTimeout } from "client/utils/fetchWithTimeout";
import { onPageResume } from "client/utils/pageLifecycle";
import { store } from "client/utils/store";
import { addError, removeError } from "client/views/admin/adminSlice";
 
// Decides how failed requests are surfaced to the user: background requests
// (the ones the app retries on its own) get their network error toast
// suppressed while offline or hidden and briefly after connectivity or the
// page resumes, with a probe request verifying suppressed failures so a
// genuine outage still surfaces; every other failure toasts immediately, and
// any successful response heals a lingering toast
 
const baseURL = config.client().apiServerUrl;
 
const PROBE_TIMEOUT_MS = 15000;
 
const networkError = (): BackendError => ({
  errorKey: BackendErrorType.NETWORK_ERROR,
});
 
// The app retries these on its own (the periodic data poll and the boot-time
// session restore), so their failures use the suppressed background error
// handling; every other request is treated as user-initiated and its
// failure toasts immediately. Keyed on method too: several endpoints pair a
// polled GET with a user-initiated POST
const BACKGROUND_REQUESTS = new Set<string>([
  `GET ${ApiEndpoint.SETTINGS}`,
  `GET ${ApiEndpoint.USERS}`,
  `GET ${ApiEndpoint.PROGRAM_ITEMS}`,
  `GET ${ApiEndpoint.GROUP}`,
  `POST ${ApiEndpoint.SESSION_RESTORE}`,
]);
 
// Background requests living in dev/test-only modules register themselves so
// their endpoints don't have to be listed above
export const registerBackgroundRequest = (
  method: string,
  endpoint: string,
): void => {
  BACKGROUND_REQUESTS.add(`${method} ${endpoint}`);
};
 
export const isBackgroundRequest = (method: string, url: string): boolean =>
  BACKGROUND_REQUESTS.has(`${method} ${url.split("?", 1)[0]}`);
 
// Background requests failing right around a connectivity change are
// expected (e.g. the first poll after a device wakes runs before Wi-Fi has
// reconnected), so their network error toast is suppressed while offline and
// briefly after the connection or the page itself resumes. Suppression is
// never final: it schedules a probe request for after the grace period, so a
// genuine outage still surfaces the toast
const RECONNECT_GRACE_PERIOD_MS = 5000;
let reconnectedAt = 0;
 
addEventListener("online", () => {
  reconnectedAt = Date.now();
});
 
// Page resume also starts the grace period: while the screen is off the
// browser freezes the page without firing offline/online events, so the
// first request after waking can run before the network is back
onPageResume(() => {
  reconnectedAt = Date.now();
});
 
// Failures on a hidden page (screen off, background tab) don't toast either:
// the resume refresh or the next visible poll fails again while visible and
// makes the call then
const shouldShowNetworkError = (): boolean =>
  navigator.onLine &&
  !document.hidden &&
  Date.now() - reconnectedAt > RECONNECT_GRACE_PERIOD_MS;
 
let lastSuccessAt = 0;
 
// Only one probe runs at a time; a probe demand arriving while one is
// already in flight is remembered and served when the current probe settles
let probeActive = false;
let probeRequested = false;
 
// Any response proves connectivity: heal a lingering network error toast,
// satisfy pending probe demand, and record the time so a probe that was
// already scheduled can recognize itself as answered and its failure as
// stale
export const onRequestSuccess = (): void => {
  lastSuccessAt = Date.now();
  probeRequested = false;
  const hasNetworkError = store
    .getState()
    .admin.errors.some(
      (error) => error.errorKey === BackendErrorType.NETWORK_ERROR,
    );
  if (hasNetworkError) {
    store.dispatch(removeError(networkError()));
  }
};
 
// Deferring the toast decision lets a resume event that is still queued
// behind a failed request start the grace period first
const NETWORK_ERROR_TOAST_DELAY_MS = 1000;
 
// A failure suppressed by the grace period can't just be dropped or a real
// outage would stay invisible on a phone that is only awake for short
// glances, so a no-op health request re-checks connectivity after the grace
// period has passed
const scheduleNetworkProbe = (): void => {
  if (probeActive) {
    probeRequested = true;
    return;
  }
  probeActive = true;
  const graceEndsIn = Math.max(
    reconnectedAt + RECONNECT_GRACE_PERIOD_MS - Date.now(),
    0,
  );
  const scheduledAt = Date.now();
  // eslint-disable-next-line @typescript-eslint/no-misused-promises
  setTimeout(async () => {
    try {
      // A request succeeding while the probe was pending already answered
      // the reachability question, so skip the request
      Eif (lastSuccessAt < scheduledAt) {
        // Demand raised before this evaluation is answered by it; demand
        // raised while the request is in flight re-sets the flag
        probeRequested = false;
        await fetchWithTimeout(
          `${baseURL}${ApiEndpoint.HEALTH}`,
          PROBE_TIMEOUT_MS,
        );
        onRequestSuccess();
      }
    } catch {
      setTimeout(() => {
        // A request succeeding after the probe was scheduled already proved
        // connectivity, making this failure stale
        Eif (lastSuccessAt < scheduledAt) {
          showNetworkErrorOrProbe();
        }
      }, NETWORK_ERROR_TOAST_DELAY_MS);
    } finally {
      probeActive = false;
      Iif (probeRequested) {
        probeRequested = false;
        scheduleNetworkProbe();
      }
    }
  }, graceEndsIn + NETWORK_ERROR_TOAST_DELAY_MS);
};
 
const showNetworkErrorOrProbe = (): void => {
  if (shouldShowNetworkError()) {
    store.dispatch(addError(networkError()));
  } else Eif (navigator.onLine && !document.hidden) {
    scheduleNetworkProbe();
  }
  // While offline or hidden nothing is scheduled — otherwise a hidden but
  // still-running page would probe in a tight loop through an outage. The
  // refresh triggered by the online event or page resume starts a new cycle
  // if the problem persists
};
 
export const onRequestFailure = (background: boolean): void => {
  if (background) {
    // Deliberately no "did another request succeed since" guard here: a
    // sibling request succeeding doesn't prove this endpoint works, and such
    // a guard would permanently mask a single persistently failing endpoint
    setTimeout(showNetworkErrorOrProbe, NETWORK_ERROR_TOAST_DELAY_MS);
  } else {
    // Failures of user-initiated requests always get immediate feedback
    store.dispatch(addError(networkError()));
  }
};
 
// While the toast is suppressed (offline, hidden, or just reconnected) an
// HTTP error on a background load is most likely a captive portal or
// gateway answering while the connection comes up, so it is handled as a
// connectivity issue instead of an API error
export const shouldTreatHttpErrorAsNetworkError = (
  background: boolean,
): boolean => background && !shouldShowNetworkError();
 
const getErrorReason = (status: number): BackendErrorType => {
  switch (status) {
    case 401:
      return BackendErrorType.UNAUTHORIZED;
    case 422:
      return BackendErrorType.INVALID_REQUEST;
    default:
      return BackendErrorType.UNKNOWN;
  }
};
 
export const showApiError = (
  method: string,
  url: string,
  status: number,
): void => {
  store.dispatch(
    addError({
      errorKey: BackendErrorType.API_ERROR,
      method,
      url,
      errorReason: getErrorReason(status),
    }),
  );
};