Appearance
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 onexercises.length.limitbounds change events, not exercises, so a full page routinely returns fewer records. - Commit
latestChangeAtand 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.deletedremoves the row;deprecatedflags 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.