Skip to content

Latest commit

 

History

History
439 lines (306 loc) · 17.3 KB

File metadata and controls

439 lines (306 loc) · 17.3 KB

Client API Layer

This is the canonical guide for the browser-side API layer in RemitWise.

Primary page-level CTAs and main flow submit or confirm actions expose stable data-testid hooks via lib/cta-testids.ts. Use those hooks for browser automation and analytics instrumentation instead of visible button text when the button is in the shared primary-CTA scope.

Use it when you are adding or reviewing client code that talks to /api/*. The goal is simple: authenticated browser requests should go through the shared client helpers so session refresh, expiry handling, and logout stay consistent across the app.

At a Glance

Use apiClient for authenticated browser requests to RemitWise API routes.

Use raw fetch only when one of these is true:

  • The request is public and does not need session expiry handling.
  • The code runs on the server, in a route handler, or in a server action.
  • You are calling a third-party service rather than the app's own API routes.
  • You intentionally do not want the shared retry and 401 -> refresh -> retry once behavior.

Related modules:

Real call sites in the repo:

For widget-style read surfaces that should stay inline while transient failures clear, use lib/client/widgetFetchRetry.ts on top of apiClient. That helper retries failed reads up to 3 times with exponential backoff before the UI shows its inline retry CTA.

apiClient Contract

apiClient exposes:

  • apiClient.request(url, options?)
  • apiClient.get(url, options?)
  • apiClient.head(url, options?)
  • apiClient.post(url, options?)
  • apiClient.put(url, options?)
  • apiClient.patch(url, options?)
  • apiClient.delete(url, options?)
  • apiClient.getJson<T>(url, options?)

ApiClientOptions extends RequestInit and adds:

  • retries?: number — max retry attempts for idempotent (GET/HEAD) requests. Default 3. Ignored for writes.
  • backoff?: number — base backoff in ms; doubles each attempt and is jittered. Default 1000.
  • timeout?: number — per-request timeout in ms before the request is aborted. Default 10000. Pass 0 to disable.

Shared authentication and request IDs

apiClient injects X-Request-ID into every request so an operator can trace a browser action through gateway logs and support reports. The API gateway accepts valid client IDs and returns the same ID in its response. One ID is kept for all transport retries and for the single replay after a successful session refresh.

The global provider also supplies Authorization: Bearer <wallet address> while a Stellar wallet is connected. Individual calls may supply Authorization or X-Request-ID in options.headers; explicit values are preserved, which is useful when forwarding a request from an integration or support tool.

Non-React consumers can set or clear the credential directly:

import { apiClient } from '@/lib/client/apiClient';

apiClient.setAuthToken('G...'); // becomes Authorization: Bearer G...
apiClient.setAuthToken(null);   // clear on logout/disconnect

Do not add these headers manually to ordinary app calls. The API middleware allows both Authorization and X-Request-ID for cross-origin requests.

Return type:

  • Promise<Response | null> for the verb helpers and request.
  • Promise<T | null> for getJson<T> (parsed/validated body, or null on session expiry).

Interpret the result like this:

  • Response: the request completed and did not enter the terminal session-expiry flow.
  • null: the session-expiry flow already ran, local auth state was cleared, and the UI was notified and scheduled for redirect. Callers should stop work and not show a duplicate auth error.

Timeout

Every request (regardless of method) runs under a per-request timeout enforced with an AbortController. The default is 10000 ms and is configurable via the timeout option (0 disables it). When the timeout fires, the in-flight fetch is aborted. For idempotent requests this counts as a retryable failure; once retries are exhausted (or for writes) the call rejects with a TimeoutError DOMException.

The timeout is composed with any caller-supplied signal, so a component unmount or useFormAction's latest-wins abort still cancels the request immediately (see Aborting requests).

Retry Behavior

Before session-expiry logic runs, apiClient uses fetchWithRetry.

Defaults:

  • retries = 3 (idempotent methods only)
  • backoff = 1000
  • timeout = 10000

Automatic retries happen only for idempotent methods (GET and HEAD), on:

  • HTTP 5xx
  • HTTP 429
  • Rejected fetches such as network failures and timeouts

Write methods (POST, PUT, PATCH, DELETE) are never auto-retried, even on 5xx/429/network errors. This is deliberate: replaying a write could cause a double-submit (e.g. sending a remittance twice). Writes still get a timeout; they just fail fast instead of retrying.

Backoff is exponential with jitter: the delay for each retry is base * 2^attempt, of which half is fixed and half is randomized ("equal jitter") so many clients don't reconverge on the server. When a response carries a Retry-After header (e.g. on 429), that value is honored instead of the computed backoff (clamped to 30s).

A caller-initiated abort is never retried — it rejects immediately.

Responses such as 400, 403, and non-expiry 401 values are returned to the caller without this retry loop treating them as session failures.

Note: this is the browser client. Do not confuse it with the server-side RPC retry in lib/soroban/client.ts; the two layers are independent and should not be stacked on the same call path.

Typed reads with getJson<T>()

getJson<T>(url, options?) is a convenience wrapper over get for JSON reads:

  1. Sends an idempotent GET (with the shared timeout + retry behavior).
  2. Returns null if the session-expiry flow ran.
  3. Throws ApiClientError if the response is not OK or the body is not valid JSON.
  4. Optionally runs a validate(data) => T function (e.g. a Zod schema's parse) and returns the typed, validated result.
import { apiClient, ApiClientError } from '@/lib/client/apiClient';

const insights = await apiClient.getJson('/api/insights', {
  validate: (raw) => InsightsSchema.parse(raw),
});

if (insights === null) return; // session-expiry flow already handled
// insights is typed and validated here

401 -> refresh -> retry once

For authenticated browser requests, the session flow is:

  1. apiClient sends the request.
  2. If the final response is not a session-expiry response, it is returned unchanged.
  3. sessionHandler.isSessionExpired() treats a response as expired only when:
    • status === 401
    • the JSON body contains message === "Session expired"
  4. On the first expired response, apiClient calls sessionHandler.refreshSession().
  5. refreshSession() sends POST /api/auth/refresh.
  6. If refresh succeeds, apiClient replays the original request once.
  7. If refresh fails, or the replayed request is still an expired-session 401, sessionHandler.handleSessionExpiry() runs and apiClient returns null.

Important details:

  • Retry-once semantics are enforced with an internal _isRetry flag. The original request is replayed at most once after refresh.
  • Concurrent 401 responses share one in-flight refresh request. sessionHandler.refreshSession() memoizes the active refresh promise so only one /api/auth/refresh call is made at a time.
  • Each waiting request still retries its own original request once after the shared refresh resolves successfully.
  • Refresh failure does not call the logout() helper. It runs the session-expiry handler directly, which clears local client auth state, emits the expiry event, stores a post-auth redirect path, and schedules a redirect to the sign-in page (/) with the current route preserved in ?next=.

Error Shape and Caller Responsibilities

apiClient does not parse JSON or normalize error payloads for you. It returns the raw Response so callers should handle three cases:

  1. response === null The session-expiry flow already started. Do not show a duplicate "please log in" error.
  2. !response.ok The request reached the API, but the route returned an error status. Parse the body and surface the route-specific error.
  3. catch (error) Fetch rejected after retries were exhausted, usually due to network failure or another transport-level error.

Pattern used in app/send/page.tsx:

try {
  const response = await apiClient.post('/api/send', {
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ recipient, amount, currency }),
  });

  if (response === null) return;

  const data = await response.json();

  if (!response.ok || !data.success) {
    // Show API-level error from route
    return;
  }

  // Success path
} catch {
  // Show network / transport error
}

Copy-Paste Examples

GET request

import { apiClient } from '@/lib/client/apiClient';

export async function loadBills() {
  try {
    const response = await apiClient.get('/api/bills');

    if (response === null) return null;
    if (!response.ok) {
      const errorBody = await response.json().catch(() => null);
      throw new Error(errorBody?.message || 'Failed to load bills');
    }

    return await response.json();
  } catch (error) {
    throw new Error(
      error instanceof Error ? error.message : 'Network error while loading bills'
    );
  }
}

POST request

import { apiClient } from '@/lib/client/apiClient';

export async function createGoal(payload: { name: string; targetAmount: number }) {
  try {
    const response = await apiClient.post('/api/goals', {
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });

    if (response === null) return { kind: 'session-expired' as const };

    const data = await response.json().catch(() => null);

    if (!response.ok) {
      return {
        kind: 'api-error' as const,
        message: data?.message || 'Unable to create goal',
      };
    }

    return { kind: 'success' as const, data };
  } catch {
    return {
      kind: 'network-error' as const,
      message: 'Network error. Please try again.',
    };
  }
}

Parallel authenticated GETs

Pattern used in app/bills/page.tsx:

const [billsRes, statsRes] = await Promise.all([
  apiClient.get('/api/bills'),
  apiClient.get('/api/bills/total-unpaid'),
]);

if (!billsRes || !statsRes) return;
if (!billsRes.ok || !statsRes.ok) {
  throw new Error('Failed to load bills data');
}

Session Expiry UI

The UI side of expiry handling is event-driven.

Provider and Notification

app/layout.tsx mounts components/SessionExpiryProvider.tsx near the top of the client tree.

The provider:

The notification has two phases:

  • warning
  • expired

Events and Hook Behavior

useSessionExpiry() listens for three window events:

  • session-expiring
  • session-expired
  • session-refresh

Behavior by event:

  • session-expiring Sets phase to warning, shows the message, and starts a one-second countdown.
  • session-expired Sets phase to expired immediately and stops any active countdown.
  • session-refresh Clears the local warning/expired UI state.

Current contract to know:

  • staySignedIn() dispatches session-refresh and resets the local notification state.
  • In the current codebase, the hook/provider do not themselves call /api/auth/refresh when that event fires.
  • The automatic refresh request currently happens in apiClient after an expired-session 401, not in the proactive warning UI path.

So if you add proactive "session about to expire" server support, you must also add a listener that turns the warning-phase session-refresh event into a real refresh request.

User-facing flow

Expired flow:

  1. An authenticated request returns 401 with message: "Session expired".
  2. apiClient attempts one refresh.
  3. If refresh cannot recover the request, sessionHandler.handleSessionExpiry() clears local auth state.
  4. handleSessionExpiry() stores redirect_after_auth when the current path is not /.
  5. It dispatches session-expired.
  6. The provider shows the expired notification.
  7. A redirect to the sign-in page with ?next=<current_route> is scheduled after 15 seconds (e.g. /?next=%2Fdashboard).

Warning flow:

  1. Some caller dispatches session-expiring, usually through sessionHandler.dispatchSessionExpiring(countdown, message).
  2. The provider shows the warning notification and countdown.
  3. Clicking "Stay signed in" clears the notification state locally by dispatching session-refresh.
  4. If the countdown reaches zero first, the hook transitions the UI into the expired state.

Logout and Post-Auth Redirects

logout() in lib/client/logout.ts is the explicit sign-out helper. It is used by the wallet/menu UI.

Contract:

  1. POST /api/auth/logout
  2. Clear local auth state with sessionHandler.clearAuthState() even if the request fails
  3. Redirect with window.location.href, defaulting to /

clearAuthState() currently removes these localStorage keys:

  • wallet_address
  • wallet_connected
  • auth_state

handleSessionExpiry() additionally stores:

  • redirect_after_auth

and redirects to /?next=<encoded_current_path> (via getSignInUrl()).

Use getPostAuthRedirect() after a successful wallet reconnect or login to read and clear that stored path.

Example:

import { getPostAuthRedirect } from '@/lib/client/logout';

const redirectPath = getPostAuthRedirect();
router.push(redirectPath || '/dashboard');

Edge Cases

Refresh failure

If /api/auth/refresh fails or returns a non-OK status, apiClient does not retry the original request again. It runs handleSessionExpiry() and returns null.

Retry-once semantics

The original request is replayed at most once after a successful refresh. A second expired-session 401 on the replay triggers the terminal expiry flow.

Aborting requests

You can pass an AbortSignal through ApiClientOptions because the options extend RequestInit.

Current implementation detail:

  • The caller's signal is combined with the internal per-request timeout signal, so either source can cancel the in-flight fetch.
  • A caller-initiated abort fails fast: it is surfaced to the caller immediately and is never retried, regardless of retries.
  • A timeout abort, by contrast, is treated as a retryable failure for idempotent GET/HEAD requests.

This makes apiClient safe to use with abort-on-unmount patterns such as useFormAction, where the latest submit aborts the previous in-flight request.

Concurrent 401s

Multiple requests can discover an expired session at the same time. They share a single refresh attempt, but each request still replays itself once after that shared refresh succeeds.

Transaction Export Utilities (export-serializer.ts)

Client-side transaction list views (/transactions and /dashboard/transaction-history) use serializeToCsv from @/lib/utils/export-serializer:

import { serializeToCsv, UTF8_BOM } from '@/lib/utils/export-serializer';

const csvString = serializeToCsv(rows); // Prepends UTF8_BOM (\uFEFF) by default
  • Excel Compatibility: Includes UTF-8 BOM (\uFEFF) by default for locale-safe rendering in Microsoft Excel.
  • Escaping & Protection: Enforces RFC 4180 field escaping and protects against Excel formula injection (=, @, \t, \r, +/-).

Contributor Checklist

Before opening a PR for client-side API work:

  • Use apiClient for authenticated browser requests.
  • Handle null, !response.ok, and thrown transport errors separately.
  • Avoid page-specific session-expiry toasts or redirects when apiClient already owns that flow.
  • Use logout() for explicit sign-out buttons.
  • If you surface warning-phase expiry, make sure the provider path is wired for a real refresh request if that is part of your feature.