All network access goes through src/lib/api. Features call apiClient from their data/ layer. The only other caller of the network is uploadFile, which uses a native upload task.

Base URL

resolveApiBaseUrl() in config.ts picks the first match:
  1. expo.extra.apiUrl from the Expo config, or process.env.EXPO_PUBLIC_API_URL. A trailing slash is trimmed.
  2. In __DEV__, the Metro host with port 3031. The host is read from Constants.expoConfig.hostUri, then the Expo Go debuggerHost, then the legacy manifest. If the host is missing or is localhost, Android uses 10.0.2.2 (the emulator’s alias for the host machine) and iOS uses localhost.
  3. Otherwise the production API, https://perform-api.otherwise.co.il.
The value has no /v1 suffix. Paths passed to the client start with /v1/trainee/. In development the resolved URL is logged once as [perform] API <url>, and each request logs its method and URL. pnpm use-lan-ip runs scripts/use-lan-ip.sh, which writes the Mac’s LAN address into .env as EXPO_PUBLIC_API_URL. It is only needed when host detection fails.

Request shape

apiClient exposes get, getRaw, post, put, patch and delete. Each calls request(). Headers on every request: post accepts a third argument for extra headers. delete accepts a JSON body, which the push token endpoint uses.

Device timezone

src/lib/time/deviceTimezone.ts reads the zone from Intl.DateTimeFormat().resolvedOptions().timeZone, falling back to getCalendars()[0].timeZone from expo-localization. The value is cached for 60 seconds so a trainee who travels is picked up without a restart. It is sent so the server can resolve calendar days in the trainee’s own zone.

Retries

Only GET requests retry, and only on a thrown network error. There are up to two retries with a 250 ms delay. Writes never retry, so a slow network cannot log a meal or a set twice. When every attempt fails the client throws ApiError with httpStatus 0. Callers treat status 0 as “no connection”. authErrors.ts and healthSyncFailure both branch on it.

Responses and errors

The backend envelope is { ok, data, requestId } on success and { ok: false, code, message, requestId } on failure.
  • On success the client returns body.data when present, else the body. getRaw returns the whole body for callers that need fields outside data.
  • On a non-2xx status it throws ApiError.
code is the backend error code. Compare it against ApiErrorCode: INTERNAL, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, RATE_LIMITED, VALIDATION, SERVICE_UNAVAILABLE. body keeps the full error payload for callers that need extra fields.

401 handling

The root layout registers one handler:
On a 401 the client calls it only when the request carried a token and that token is still the current one:
That check covers two races that scripts/test-trainee-session.cjs pins down:
  • A request sent before the saved token loaded gets a 401 with no token. It must not sign anyone out.
  • A late 401 for a token that has since been replaced, by a studio switch or a new sign in, must not sign the new session out.

Token storage

The token lives in two places. authStore.hydrate() copies the stored token into memory at launch. verifyOtp and switchStudio write both. logout clears both. Details, including the iOS keychain accessibility class, are in Authentication. Background code cannot rely on hydration having run. syncHealthData() calls its own ensureAuthToken(), which loads the token from storage when memory is empty.

Read-only preview

When the app runs embedded in the coach dashboard, IS_PREVIEW is true and every non-GET request is rejected before it leaves the device:
PREVIEW_READONLY_ERROR is 'preview-is-read-only'. See Web preview export.

React Query defaults

queryClient.ts:
Hooks override staleTime where needed. The store is imported directly by authStore.ts so sign out and studio switch can call queryClient.clear().

File uploads

uploadFile() in upload.ts sends one file to POST /v1/trainee/uploads as a raw binary body with createUploadTask from expo-file-system/legacy. It returns { fileUrl }. Behaviour:
  • Images are shrunk first by prepareImageForUpload. See Tracking and uploads.
  • onProgress receives a fraction from 0 to 1.
  • Up to 3 attempts. Retries happen for statuses 0, 408, 429, 502, 503 and 504, after 1 second and then 3 seconds. Progress resets to 0 before each retry.
  • isPayloadTooLarge(error) is true for status 413, which screens use to show a “too large” message instead of a generic failure.
The upload path does not call the 401 handler. A rejected upload surfaces as an error to the screen.