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:
expo.extra.apiUrlfrom the Expo config, orprocess.env.EXPO_PUBLIC_API_URL. A trailing slash is trimmed.- In
__DEV__, the Metro host with port3031. The host is read fromConstants.expoConfig.hostUri, then the Expo GodebuggerHost, then the legacy manifest. If the host is missing or islocalhost, Android uses10.0.2.2(the emulator’s alias for the host machine) and iOS useslocalhost. - Otherwise the production API,
https://perform-api.otherwise.co.il.
/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
OnlyGET 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.datawhen present, else the body.getRawreturns the whole body for callers that need fields outsidedata. - 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: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:
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. onProgressreceives a fraction from 0 to 1.- Up to 3 attempts. Retries happen for statuses
0,408,429,502,503and504, after 1 second and then 3 seconds. Progress resets to 0 before each retry. isPayloadTooLarge(error)is true for status413, which screens use to show a “too large” message instead of a generic failure.