Production Integration Patterns

This guide shows production-ready patterns for integrating the Fluency API. All examples assume you have an OpenAPI-generated client from the API Reference.

Session State Management

Track session state across questions and handle pause/resume:

interface SessionState {
  sessionId: string;
  skillId: string;
  algorithmId: 'speedrun' | 'practice' | 'review';
  activeSessionTime: number; // seconds
  lives: number;
  isPaused: boolean;
  questionCount: number;
}

// Initialize
const session: SessionState = {
  sessionId: crypto.randomUUID(),
  skillId: selectedSkill.id,
  algorithmId: 'practice',
  activeSessionTime: 0,
  lives: 3,
  isPaused: false,
  questionCount: 0,
};

// Pause tracking
function pauseSession() {
  session.isPaused = true;
  stopTimer();
}

// Resume with same sessionId
function resumeSession() {
  session.isPaused = false;
  startTimer();
}

Session Persistence

For apps that may close mid-session:

// Save after each answer
localStorage.setItem('current_session', JSON.stringify(session));

// Restore on reopen
const saved = localStorage.getItem('current_session');
if (saved) {
  const state = JSON.parse(saved);

  // Validate not stale (< 1 hour old)
  const age = Date.now() - state.startedAt;
  if (age < 60 * 60 * 1000) {
    session = state; // Resume
  }
}

Error Handling

Network Errors with Retry

async function fetchWithRetry<T>(fetchFn: () => Promise<T>, maxRetries = 3): Promise<T> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fetchFn();
    } catch (error) {
      const isLastAttempt = attempt === maxRetries - 1;

      // Don't retry client errors (4xx)
      if (error.statusCode >= 400 && error.statusCode < 500) {
        throw error;
      }

      if (isLastAttempt) throw error;

      // Exponential backoff: 1s, 2s, 4s
      await delay(Math.pow(2, attempt) * 1000);
    }
  }
}

// Usage
const { question } = await fetchWithRetry(() => api.getNextQuestion(skillId, algorithmId, sessionId));

Auth Token Expiry

async function makeRequest<T>(requestFn: () => Promise<T>): Promise<T> {
  try {
    return await requestFn();
  } catch (error) {
    // Token expired - refresh and retry once
    if (error.statusCode === 401) {
      await refreshToken();
      return await requestFn();
    }
    throw error;
  }
}

// With Amplify
async function refreshToken() {
  await fetchAuthSession({ forceRefresh: true });
}

Handling API Errors

// Error response formats
// Standard error:
// { statusCode: 400, message: "Bad Request", error: "sessionId is required" }

// Validation error:
// { statusCode: 400, message: "Validation failed", errors: [{ field: "timeTookToAnswerMs", message: "..." }] }

try {
  const feedback = await api.submitAnswer(/* ... */);
} catch (error) {
  if (error.statusCode === 400) {
    // Bad request - likely our bug
    console.error('Invalid request:', error.message);
    showMessage('Something went wrong. Please restart.');
  } else if (error.statusCode === 401) {
    // Auth expired
    showMessage('Session expired. Please sign in again.');
    redirectToLogin();
  } else if (error.statusCode >= 500) {
    // Server error - retryable
    showMessage('Server error. Retrying...');
    await retry();
  }
}

Handling sessionStatus and availabilityStatus

getNextQuestion and submitAnswer return the live-session outcome on sessionStatus, plus availabilityStatus, which always reports the skill's current playability (Available unless the skill cannot be played right now, e.g. AllFactsOnCooldown). availabilityStatus reflects the skill's standalone practice state, so it stays meaningful even inside a competition or review session and even while sessionStatus is InProgress. Skill mastery is on neither field: read it from SkillWithState.state === 'COMPLETED'.

const { question, sessionStatus, availabilityStatus } = await api.getNextQuestion(/* ... */);

switch (sessionStatus) {
  case 'InProgress':
    displayQuestion(question);
    break;

  case 'SessionCompleted':
    // The run finished (practice ran out of facts, or a competition/review round ended).
    // This does NOT mean the skill is mastered; check SkillWithState.state for that.
    showMessage('Session complete!');
    endSession('completed');
    break;

  case 'SkillUnavailable':
    // The skill cannot be played right now; availabilityStatus holds the reason.
    if (availabilityStatus === 'AllFactsOnCooldown') {
      showMessage('All facts are resting. Come back later!');
    }
    endSession('unavailable');
    break;

  case 'ExceededStrugglingThreshold':
    // The learner is struggling; suspend the session.
    endSession('struggling');
    break;

  case 'SessionCompletionRecommended':
    // Soft nudge to wrap up; the session can still continue.
    showMessage('Great work, consider wrapping up.');
    break;
}

Handling Review Sessions

const { question, startReview } = await api.getNextQuestion(skillId, algorithmId, sessionId);

if (startReview) {
  const reviewRes = await api.getNextQuestion(skillId, 'review', sessionId, {
    factSetId: startReview.factSetId,
  });
  // Render reviewRes.question and route answers to the review algorithm
  return;
}

Notes:

  • Always pass a factSetId when requesting review questions. Use startReview.factSetId from the practice response.
  • Review state is keyed by skillId + sessionId. If you re-use the same sessionId across multiple review segments, you may see stale review stats. Prefer generating a new review session ID each time startReview appears, while keeping the practice session ID stable.

Best Practices

Timing Accuracy

// ✅ Good: Track per-question timing
const start = Date.now();
const timeTookMs = Date.now() - start;

// ❌ Bad: Use fixed values
const timeTookMs = 3000; // API needs real timing

Active Session Time

// ✅ Good: Only count learning time
onQuestionDisplay(() => startTimer());
onPauseMenu(() => stopTimer());

// ❌ Bad: Count everything including menus
const activeTime = Date.now() - sessionStart; // Wrong!

Session ID Persistence

// ✅ Good: One sessionId per session
const sessionId = crypto.randomUUID();
// Use for all questions in this session

// ❌ Bad: New sessionId per question
await askQuestion(crypto.randomUUID()); // Wrong!

Error Recovery

// ✅ Good: Save state, offer recovery
try {
  saveState(session);
  await api.submitAnswer(/* ... */);
} catch (error) {
  showDialog(['Retry', 'End Session', 'Menu']);
}

// ❌ Bad: Silent failure
catch (error) {
  console.error(error); // User sees nothing
}

Getting Started

Authentication setup and basic learning loop. Start here if you're new to the API.

Interventions Guide

Detailed breakdown of each intervention type with UI implementation guidance and reference videos.

Timing Fields Guide

Critical timing implementation details for timeTookToAnswerMs and activeSessionDurationSec.

API Reference

Complete endpoint documentation. Generate a typed client from the OpenAPI spec to use in your code.