Skip to content

Examples

A complete client in five languages. Each authenticates, handles an error, and runs the full sync loop.

These are the real files from examples/, included at build time rather than pasted, so they cannot drift from the code you can actually run.

Read the sync guide first

The loop below will make more sense after the sync guide, which explains why you page on hasMore and why the watermark is committed last.

Authenticate and list

Send your key in x-api-key. Every error is application/problem+json; branch on the code member, which is stable.

The sync loop

Three things every one of these clients gets right, and most hand-written clients get wrong:

  • Page on hasMore, never on exercises.length. limit bounds change events, not exercises, so a full page routinely returns fewer records.
  • Commit latestChangeAt and the records in one transaction. It is identical on every page of a sync, so which page you read it from does not matter — but a watermark that lands without its records skips data permanently.
  • Branch on changeType. deleted removes the row; deprecated flags it.

Full source

js
/**
 * ExerciseDB API client — JavaScript (Node 18+, or any modern browser).
 *
 * Run: EXERCISEDB_API_KEY=exdb_… node examples/javascript/exercisedb.js
 */

const BASE_URL =
  process.env.EXERCISEDB_BASE_URL ?? 'https://api.harshitbishnoi.dev';
const API_KEY = process.env.EXERCISEDB_API_KEY;
const PAGE_SIZE = 100;

/** Errors are RFC 9457 problem+json. `code` is stable; `detail` is prose. */
class ApiError extends Error {
  constructor(problem) {
    super(problem.detail ?? 'Request failed');
    this.name = 'ApiError';
    this.status = problem.status;
    this.code = problem.code;
  }
}

async function get(path) {
  const response = await fetch(`${BASE_URL}${path}`, {
    headers: { 'x-api-key': API_KEY }
  });
  const body = await response.json();

  if (!response.ok) {
    throw new ApiError(body);
  }

  return body;
}

async function listExercises({ limit = 5 } = {}) {
  const { data } = await get(`/exercises?limit=${limit}`);
  return data;
}

/**
 * Walks every page of a sync and applies it to a local store.
 *
 * `limit` bounds change events, not exercises: an exercise created and then
 * updated inside the window is one record from two events, and deleted records
 * are dropped entirely. So page on `hasMore`, never on `exercises.length`.
 */
async function sync(store) {
  const since = store.getWatermark();
  let cursor = null;
  let watermark = since;
  const pages = [];

  do {
    const params = new URLSearchParams({ limit: String(PAGE_SIZE) });
    if (since) params.set('updated_since', since);
    if (cursor) params.set('cursor', cursor);

    const page = await get(`/sync/exercises?${params}`);
    pages.push(page.data);

    // Identical on every page of one sync: the server reads it before the
    // first page, so a record written mid-sync arrives on the next run
    // instead of being skipped.
    watermark = page.data.latestChangeAt ?? watermark;
    cursor = page.pagination.nextCursor;
  } while (cursor);

  // Records and watermark must commit together. A watermark that lands without
  // its records means the next sync starts after data you never wrote.
  store.transaction(() => {
    for (const { exercises, tombstones } of pages) {
      for (const exercise of exercises) {
        store.upsert(exercise);
      }

      for (const tombstone of tombstones) {
        if (tombstone.changeType === 'deleted') {
          store.remove(tombstone.exerciseId);
        } else {
          store.markDeprecated(tombstone.exerciseId);
        }
      }
    }

    store.setWatermark(watermark);
  });
}

async function main() {
  if (!API_KEY) {
    throw new Error('Set EXERCISEDB_API_KEY');
  }

  try {
    for (const exercise of await listExercises({ limit: 3 })) {
      console.info(`${exercise.slug} — ${exercise.name}`);
    }
  } catch (error) {
    if (error instanceof ApiError && error.code === 'RATE_LIMIT_EXCEEDED') {
      console.error('Daily quota exhausted. Retry after midnight UTC.');
      return;
    }

    throw error;
  }

  await sync(createMemoryStore());
}

/** Stand-in for your real database. */
function createMemoryStore() {
  const exercises = new Map();
  let watermark = null;

  return {
    getWatermark: () => watermark,
    setWatermark: (value) => {
      watermark = value;
    },
    upsert: (exercise) => exercises.set(exercise.id, exercise),
    remove: (id) => exercises.delete(id),
    markDeprecated: (id) => {
      const exercise = exercises.get(id);
      if (exercise) {
        exercise.status = 'deprecated';
      }
    },
    transaction: (work) => work()
  };
}

main().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});
py
"""ExerciseDB API client — Python 3.9+ (standard library only).

Run: EXERCISEDB_API_KEY=exdb_... python examples/python/exercisedb.py
"""

from __future__ import annotations

import json
import os
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Callable, Iterator

BASE_URL = os.environ.get("EXERCISEDB_BASE_URL", "https://api.harshitbishnoi.dev")
API_KEY = os.environ.get("EXERCISEDB_API_KEY", "")
PAGE_SIZE = 100


class ApiError(Exception):
    """Errors are RFC 9457 problem+json. `code` is stable; `detail` is prose."""

    def __init__(self, problem: dict[str, Any]) -> None:
        super().__init__(problem.get("detail", "Request failed"))
        self.status = problem.get("status")
        self.code = problem.get("code")


def get(path: str) -> dict[str, Any]:
    request = urllib.request.Request(
        f"{BASE_URL}{path}", headers={"x-api-key": API_KEY}
    )

    try:
        with urllib.request.urlopen(request) as response:
            return json.load(response)
    except urllib.error.HTTPError as error:
        raise ApiError(json.loads(error.read())) from error


def list_exercises(limit: int = 5) -> list[dict[str, Any]]:
    return get(f"/exercises?limit={limit}")["data"]


def sync_pages(since: str | None) -> Iterator[dict[str, Any]]:
    """Yields every page of one sync.

    `limit` bounds change events, not exercises: an exercise created and then
    updated inside the window is one record from two events, and deleted records
    are dropped entirely. So follow the cursor, never `len(exercises)`.
    """
    cursor: str | None = None

    while True:
        query: dict[str, str] = {"limit": str(PAGE_SIZE)}
        if since:
            query["updated_since"] = since
        if cursor:
            query["cursor"] = cursor

        page = get(f"/sync/exercises?{urllib.parse.urlencode(query)}")
        yield page

        cursor = page["pagination"]["nextCursor"]
        if not cursor:
            return


def sync(store: "MemoryStore") -> None:
    since = store.watermark
    watermark = since
    pages: list[dict[str, Any]] = []

    for page in sync_pages(since):
        pages.append(page["data"])
        # Identical on every page of one sync: the server reads it before the
        # first page, so a record written mid-sync arrives on the next run
        # instead of being skipped.
        watermark = page["data"]["latestChangeAt"] or watermark

    # Records and watermark must commit together. A watermark that lands without
    # its records means the next sync starts after data you never wrote.
    with store.transaction():
        for data in pages:
            for exercise in data["exercises"]:
                store.upsert(exercise)

            for tombstone in data["tombstones"]:
                if tombstone["changeType"] == "deleted":
                    store.remove(tombstone["exerciseId"])
                else:
                    store.mark_deprecated(tombstone["exerciseId"])

        store.watermark = watermark


class MemoryStore:
    """Stand-in for your real database."""

    def __init__(self) -> None:
        self.exercises: dict[str, dict[str, Any]] = {}
        self.watermark: str | None = None

    def upsert(self, exercise: dict[str, Any]) -> None:
        self.exercises[exercise["id"]] = exercise

    def remove(self, exercise_id: str) -> None:
        self.exercises.pop(exercise_id, None)

    def mark_deprecated(self, exercise_id: str) -> None:
        if exercise_id in self.exercises:
            self.exercises[exercise_id]["status"] = "deprecated"

    def transaction(self) -> Any:
        import contextlib

        return contextlib.nullcontext()


def main() -> None:
    if not API_KEY:
        raise SystemExit("Set EXERCISEDB_API_KEY")

    try:
        for exercise in list_exercises(limit=3):
            # Plain hyphen, not an em dash: Windows consoles default to a
            # codepage that cannot encode it and raise on print.
            print(f"{exercise['slug']} - {exercise['name']}")
    except ApiError as error:
        if error.code == "RATE_LIMIT_EXCEEDED":
            raise SystemExit("Daily quota exhausted. Retry after midnight UTC.")
        raise

    sync(MemoryStore())


if __name__ == "__main__":
    main()
swift
// ExerciseDB API client — Swift 5.7+ (Foundation only, async/await).
//
// On Linux, add: import FoundationNetworking

import Foundation

private let baseURL = "https://api.harshitbishnoi.dev"
private let pageSize = 100

/// Errors are RFC 9457 problem+json. `code` is stable; `detail` is prose.
struct ApiError: Error, Decodable {
    let status: Int
    let code: String
    let detail: String
}

struct Exercise: Decodable {
    let id: String
    let slug: String
    let name: String
    var status: String
}

struct Tombstone: Decodable {
    let exerciseId: String
    let changeType: String
}

struct Pagination: Decodable {
    let nextCursor: String?
    let hasMore: Bool
}

struct SyncPage: Decodable {
    let exercises: [Exercise]
    let tombstones: [Tombstone]
    /// Null only when the catalog has never recorded a change.
    let latestChangeAt: String?
}

private struct Envelope<T: Decodable>: Decodable {
    let data: T
    let pagination: Pagination?
}

struct ExerciseDBClient {
    let apiKey: String
    private let session = URLSession.shared
    private let decoder = JSONDecoder()

    private func get<T: Decodable>(_ path: String) async throws -> Envelope<T> {
        var request = URLRequest(url: URL(string: baseURL + path)!)
        request.setValue(apiKey, forHTTPHeaderField: "x-api-key")

        let (data, response) = try await session.data(for: request)
        let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0

        guard (200..<300).contains(statusCode) else {
            throw try decoder.decode(ApiError.self, from: data)
        }

        return try decoder.decode(Envelope<T>.self, from: data)
    }

    func listExercises(limit: Int = 5) async throws -> [Exercise] {
        let envelope: Envelope<[Exercise]> = try await get("/exercises?limit=\(limit)")
        return envelope.data
    }

    /// Walks every page of a sync and applies it to `store`.
    ///
    /// `limit` bounds change events, not exercises: an exercise created and then
    /// updated inside the window is one record from two events, and deleted
    /// records are dropped entirely. So follow the cursor, never `count`.
    func sync(into store: LocalStore) async throws {
        let since = store.watermark
        var cursor: String?
        var watermark = since
        var pages: [SyncPage] = []

        repeat {
            var query = "limit=\(pageSize)"
            if let since { query += "&updated_since=\(since)" }
            if let cursor { query += "&cursor=\(cursor)" }

            let envelope: Envelope<SyncPage> = try await get("/sync/exercises?\(query)")
            pages.append(envelope.data)

            // Identical on every page of one sync: the server reads it before
            // the first page, so a record written mid-sync arrives on the next
            // run instead of being skipped.
            watermark = envelope.data.latestChangeAt ?? watermark
            cursor = envelope.pagination?.nextCursor
        } while cursor != nil

        // Records and watermark must commit together. A watermark that lands
        // without its records means the next sync starts after data you never
        // wrote.
        store.transaction {
            for page in pages {
                for exercise in page.exercises {
                    store.upsert(exercise)
                }

                for tombstone in page.tombstones {
                    if tombstone.changeType == "deleted" {
                        store.remove(tombstone.exerciseId)
                    } else {
                        store.markDeprecated(tombstone.exerciseId)
                    }
                }
            }

            store.watermark = watermark
        }
    }
}

/// Stand-in for your real database.
final class LocalStore {
    private(set) var exercises: [String: Exercise] = [:]
    var watermark: String?

    func upsert(_ exercise: Exercise) { exercises[exercise.id] = exercise }
    func remove(_ id: String) { exercises.removeValue(forKey: id) }

    func markDeprecated(_ id: String) {
        exercises[id]?.status = "deprecated"
    }

    func transaction(_ work: () -> Void) { work() }
}

// Usage:
//
//   let client = ExerciseDBClient(apiKey: ProcessInfo.processInfo
//       .environment["EXERCISEDB_API_KEY"] ?? "")
//
//   do {
//       for exercise in try await client.listExercises(limit: 3) {
//           print("\(exercise.slug) — \(exercise.name)")
//       }
//       try await client.sync(into: LocalStore())
//   } catch let error as ApiError where error.code == "RATE_LIMIT_EXCEEDED" {
//       print("Daily quota exhausted. Retry after midnight UTC.")
//   }
dart
/// ExerciseDB API client — Dart / Flutter (dart:io + dart:convert only).
///
/// Run: dart run examples/dart/exercisedb.dart
library;

import 'dart:convert';
import 'dart:io';

const String baseUrl = 'https://api.harshitbishnoi.dev';
const int pageSize = 100;

/// Errors are RFC 9457 problem+json. `code` is stable; `detail` is prose.
class ApiError implements Exception {
  ApiError(this.status, this.code, this.detail);

  final int status;
  final String code;
  final String detail;

  @override
  String toString() => 'ApiError($code): $detail';
}

class ExerciseDbClient {
  ExerciseDbClient(this.apiKey, {HttpClient? httpClient})
      : _http = httpClient ?? HttpClient();

  final String apiKey;
  final HttpClient _http;

  Future<Map<String, dynamic>> _get(String path) async {
    final request = await _http.getUrl(Uri.parse('$baseUrl$path'));
    request.headers.set('x-api-key', apiKey);

    final response = await request.close();
    final body = jsonDecode(await response.transform(utf8.decoder).join())
        as Map<String, dynamic>;

    if (response.statusCode >= 400) {
      throw ApiError(
        body['status'] as int? ?? response.statusCode,
        body['code'] as String? ?? 'UNKNOWN_ERROR',
        body['detail'] as String? ?? 'Request failed',
      );
    }

    return body;
  }

  Future<List<dynamic>> listExercises({int limit = 5}) async {
    final body = await _get('/exercises?limit=$limit');
    return body['data'] as List<dynamic>;
  }

  /// Walks every page of a sync and applies it to [store].
  ///
  /// `limit` bounds change events, not exercises: an exercise created and then
  /// updated inside the window is one record from two events, and deleted
  /// records are dropped entirely. So follow the cursor, never the list length.
  Future<void> sync(LocalStore store) async {
    final String? since = store.watermark;
    String? cursor;
    String? watermark = since;
    final List<Map<String, dynamic>> pages = <Map<String, dynamic>>[];

    do {
      final query = <String, String>{'limit': '$pageSize'};
      if (since != null) query['updated_since'] = since;
      if (cursor != null) query['cursor'] = cursor;

      final page = await _get('/sync/exercises?${_encode(query)}');
      final data = page['data'] as Map<String, dynamic>;
      pages.add(data);

      // Identical on every page of one sync: the server reads it before the
      // first page, so a record written mid-sync arrives on the next run
      // instead of being skipped.
      watermark = data['latestChangeAt'] as String? ?? watermark;
      cursor = (page['pagination'] as Map<String, dynamic>)['nextCursor']
          as String?;
    } while (cursor != null);

    // Records and watermark must commit together. A watermark that lands
    // without its records means the next sync starts after data you never
    // wrote.
    store.transaction(() {
      for (final data in pages) {
        for (final exercise in data['exercises'] as List<dynamic>) {
          store.upsert(exercise as Map<String, dynamic>);
        }

        for (final tombstone in data['tombstones'] as List<dynamic>) {
          final stone = tombstone as Map<String, dynamic>;
          final id = stone['exerciseId'] as String;

          if (stone['changeType'] == 'deleted') {
            store.remove(id);
          } else {
            store.markDeprecated(id);
          }
        }
      }

      store.watermark = watermark;
    });
  }

  static String _encode(Map<String, String> query) =>
      query.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&');
}

/// Stand-in for your real database.
class LocalStore {
  final Map<String, Map<String, dynamic>> exercises =
      <String, Map<String, dynamic>>{};
  String? watermark;

  void upsert(Map<String, dynamic> exercise) {
    exercises[exercise['id'] as String] = exercise;
  }

  void remove(String id) => exercises.remove(id);

  void markDeprecated(String id) {
    exercises[id]?['status'] = 'deprecated';
  }

  void transaction(void Function() work) => work();
}

Future<void> main() async {
  final apiKey = Platform.environment['EXERCISEDB_API_KEY'];

  if (apiKey == null || apiKey.isEmpty) {
    stderr.writeln('Set EXERCISEDB_API_KEY');
    exitCode = 1;
    return;
  }

  final client = ExerciseDbClient(apiKey);

  try {
    for (final exercise in await client.listExercises(limit: 3)) {
      final row = exercise as Map<String, dynamic>;
      stdout.writeln('${row['slug']}${row['name']}');
    }
  } on ApiError catch (error) {
    if (error.code == 'RATE_LIMIT_EXCEEDED') {
      stderr.writeln('Daily quota exhausted. Retry after midnight UTC.');
      exitCode = 1;
      return;
    }
    rethrow;
  }

  await client.sync(LocalStore());
}
kt
// ExerciseDB API client — Kotlin (JVM 11+), java.net.http + kotlinx.serialization.
//
// build.gradle.kts:
//   plugins { kotlin("plugin.serialization") version "2.0.0" }
//   dependencies {
//     implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
//   }

package dev.exercisedb.example

import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

private const val BASE_URL = "https://api.harshitbishnoi.dev"
private const val PAGE_SIZE = 100

/** Errors are RFC 9457 problem+json. `code` is stable; `detail` is prose. */
@Serializable
data class Problem(val status: Int, val code: String, val detail: String)

class ApiException(val problem: Problem) : RuntimeException(problem.detail)

@Serializable
data class Exercise(
    val id: String,
    val slug: String,
    val name: String,
    var status: String
)

@Serializable
data class Tombstone(val exerciseId: String, val changeType: String)

@Serializable
data class Pagination(val nextCursor: String? = null, val hasMore: Boolean)

@Serializable
data class SyncPage(
    val exercises: List<Exercise>,
    val tombstones: List<Tombstone>,
    /** Null only when the catalog has never recorded a change. */
    val latestChangeAt: String? = null
)

@Serializable
data class Envelope<T>(val data: T, val pagination: Pagination? = null)

class ExerciseDbClient(private val apiKey: String) {
    private val http: HttpClient = HttpClient.newHttpClient()
    private val json = Json { ignoreUnknownKeys = true }

    private fun fetch(path: String): String {
        val request = HttpRequest.newBuilder()
            .uri(URI.create("$BASE_URL$path"))
            .header("x-api-key", apiKey)
            .GET()
            .build()

        val response = http.send(request, HttpResponse.BodyHandlers.ofString())

        if (response.statusCode() >= 400) {
            throw ApiException(json.decodeFromString<Problem>(response.body()))
        }

        return response.body()
    }

    fun listExercises(limit: Int = 5): List<Exercise> =
        json.decodeFromString<Envelope<List<Exercise>>>(
            fetch("/exercises?limit=$limit")
        ).data

    /**
     * Walks every page of a sync and applies it to [store].
     *
     * `limit` bounds change events, not exercises: an exercise created and then
     * updated inside the window is one record from two events, and deleted
     * records are dropped entirely. So follow the cursor, never `size`.
     */
    fun sync(store: LocalStore) {
        val since = store.watermark
        var cursor: String? = null
        var watermark = since
        val pages = mutableListOf<SyncPage>()

        do {
            val query = buildString {
                append("limit=$PAGE_SIZE")
                since?.let { append("&updated_since=$it") }
                cursor?.let { append("&cursor=$it") }
            }

            val page = json.decodeFromString<Envelope<SyncPage>>(
                fetch("/sync/exercises?$query")
            )
            pages.add(page.data)

            // Identical on every page of one sync: the server reads it before
            // the first page, so a record written mid-sync arrives on the next
            // run instead of being skipped.
            watermark = page.data.latestChangeAt ?: watermark
            cursor = page.pagination?.nextCursor
        } while (cursor != null)

        // Records and watermark must commit together. A watermark that lands
        // without its records means the next sync starts after data you never
        // wrote.
        store.transaction {
            for (page in pages) {
                page.exercises.forEach(store::upsert)

                for (tombstone in page.tombstones) {
                    if (tombstone.changeType == "deleted") {
                        store.remove(tombstone.exerciseId)
                    } else {
                        store.markDeprecated(tombstone.exerciseId)
                    }
                }
            }

            store.watermark = watermark
        }
    }
}

/** Stand-in for your real database. */
class LocalStore {
    val exercises = mutableMapOf<String, Exercise>()
    var watermark: String? = null

    fun upsert(exercise: Exercise) {
        exercises[exercise.id] = exercise
    }

    fun remove(id: String) {
        exercises.remove(id)
    }

    fun markDeprecated(id: String) {
        exercises[id]?.status = "deprecated"
    }

    fun transaction(work: () -> Unit) = work()
}

fun main() {
    val apiKey = System.getenv("EXERCISEDB_API_KEY")

    if (apiKey.isNullOrEmpty()) {
        System.err.println("Set EXERCISEDB_API_KEY")
        return
    }

    val client = ExerciseDbClient(apiKey)

    try {
        client.listExercises(limit = 3).forEach { println("${it.slug} — ${it.name}") }
    } catch (error: ApiException) {
        if (error.problem.code == "RATE_LIMIT_EXCEEDED") {
            System.err.println("Daily quota exhausted. Retry after midnight UTC.")
            return
        }
        throw error
    }

    client.sync(LocalStore())
}

Where next

Every parameter and response shape is in the API reference.

Released under the MIT License.