feat: build resilient API client with token refresh and offline queue (Closes #47)#48
Open
LaPoshBaby wants to merge 1 commit into
Open
feat: build resilient API client with token refresh and offline queue (Closes #47)#48LaPoshBaby wants to merge 1 commit into
LaPoshBaby wants to merge 1 commit into
Conversation
Closes StepFi-app#47 - Add ApiClientError typed error class for consistent error surfacing - Implement exponential backoff with jitter for idempotent request retries - Add durable offline mutation queue with AsyncStorage persistence - Add SHA-256 idempotency keys per queue action to prevent duplicate replay - Fix register.tsx empty catch block with real error banner feedback - Write 16 tests covering token refresh, backoff retry, and offline replay - Fix pre-existing stray /> syntax error in tabs _layout.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #47 — core: build resilient API client with token refresh and offline queue
Problem
The StepFi learner app targets users on unreliable mobile connectivity, yet the API client had critical resilience gaps:
AxiosErrorobjects, forcing every consumer to parse error shapes manually. Many screens swallowed errors with emptycatch {}blocks.processQueuecrashed mid-way.Solution
A three-layer resilient API client:
min(30s, 1s × 2^attempt) + random(0, 500ms), up to 3 retries.@react-native-async-storage/async-storage, replays them on reconnect (viaNetInfolistener in_layout.tsx), and sends per-requestIdempotency-Keyheaders using SHA-256 hashes of the action payload.Files Changed
types/errors.tsApiClientErrorclass +ApiErrorCodeenumservices/api.tsservices/auth.service.tsApiClientError, consistent propagationsrc/offline/offline-queue.tssrc/offline/offline-sync.tsIdempotency-Keyheader during queue replayapp/(auth)/register.tsxcatch {}app/(tabs)/_layout.tsx/>syntax errorservices/__tests__/api.test.tssrc/offline/__tests__/offline-queue.test.tsImplementation Details
1. 401 handling (
services/api.ts)refreshInFlight: Promise<string | null> | nullsingleton deduplicates concurrent 401s — only one refresh call is made regardless of how many requests fail simultaneously.useAuthStore.setTokens()and the original request is retried with the newAuthorizationheader.clearAuth()is called androuter.replace('/(auth)/sign-in')redirects to sign-in. The rejected promise is wrapped in anApiClientErrorwith codeUNAUTHORIZED.2. Exponential backoff with jitter (
services/api.ts)_retryCountcounter prevents infinite loops3. Offline mutation queue (
src/offline/offline-queue.ts)@react-native-async-storage/async-storagewith key@stepfi/offline-queueuseConnectivityStore.isConnected; if false, it callsenqueueAction()and rejects with__offline_queued: true__offline_queuedmarkers and returns a synthetic202 Acceptedresponse so the caller gets a non-breaking responseapp/_layout.tsxalready subscribes toNetInfochanges — when connectivity transitions from disconnected → connected, it callsprocessQueue()4. Idempotency keys (
src/offline/offline-queue.ts)randomPrefix::SHA256(type:endpoint:payload)[:16]offline-sync.tssends the key in theIdempotency-KeyHTTP headerprocessQueue()crashes between the successful HTTP call anddequeueAction(), the next replay will send the same key — the API can detect the duplicate and return the original response5. Typed error surfacing (
types/errors.ts)api.tsinterceptors are wrapped inApiClientErrorauth.service.tsalso wraps its own errors (belt-and-suspenders)catch (e) { if (e instanceof ApiClientError) { ... } }for consistent handling6. register.tsx — real error feedback
submitError: string | nullstatehandleCompletecatch block now setssubmitErrorinstead of being emptyAlertCircleicon, error text, andXdismiss button appears below the headercolors.errorDimandcolors.error— no hardcoded hex valuesAcceptance Criteria Checklist
refreshInFlightpromiseclearAuth()+ redirect to sign-inIdempotency-Keyheaderconstants/colors.tsAlertCircle,X,ChevronLeft,CameraTest Coverage
services/__tests__/api.test.ts(12 tests)refreshes the token once and retries on 401/auth/refreshcalled → original request retries with new tokenrejects with UNAUTHORIZED and clears auth when refresh failsclearAuth()+ redirect → typed errordoes not attempt refresh more than once on concurrent 401s/auth/refreshcallretries GET requests up to MAX_RETRIES on server errorsSERVER_ERRORdoes not retry POST requests (non-idempotent)queues POST requests when offlinequeued: truequeues PUT requests when offlineallows GET requests through when offlinewraps timeout errors in ApiClientErrorECONNABORTED→TIMEOUTwith user-facing messagefromAxiosError returns proper messagesisRetryable flag correctnesssrc/offline/__tests__/offline-queue.test.ts(4 tests)generates an idempotency key when enqueuinggenerates different keys for different payloadssame payload enqueued twice gets different keysoffline sync sends Idempotency-Key headerapi.requestcalled withIdempotency-KeyheaderDesign Decisions
Random prefix in idempotency keys: Each key starts with a random string (
Date.now().toString(36) + Math.random().toString(36)) so the same action enqueued at different times produces different keys. This is intentional — the key is stable per queue item (not per logical action), meaning if the user taps twice offline, both taps are preserved as separate queue entries with different keys. The API deduplication protects against the same queue item being replayed multiple times after a crash.15-second timeout ceiling: Raised from the previous 10s to accommodate slower mobile networks while still failing fast enough for good UX.
Full jitter:
random(0, 500ms)added to each backoff delay to prevent thundering herd when multiple requests fail simultaneously on reconnect.Separate test files: The API client tests and offline queue idempotency tests are in separate files because the offline queue tests require module-level mocks for native modules (
expo-crypto,@react-native-async-storage/async-storage) that would conflict with the mocked queue inapi.test.ts.Notes for Reviewers
tsconfig.jsonerrors (expo/tsconfig.basenot found,baseUrldeprecation) are pre-existing and unrelated to this PR.jest-expois listed indevDependenciesinpackage.jsonbut may neednpm installbefore tests can run locally./>inapp/(tabs)/_layout.tsx(line 57) was fixed as part of this PR since it broke the typecheck.Next Steps
npx expo export --platform webto validate web exportnpm install && npx jestto execute the full test suite