Skip to content

Latest commit

Β 

History

History
352 lines (270 loc) Β· 9.55 KB

File metadata and controls

352 lines (270 loc) Β· 9.55 KB

SDK Architecture

This document describes the internal architecture, design decisions, and patterns used in @luziadev/sdk.

Design Goals

  1. Zero runtime dependencies - Uses only native browser/Node.js APIs (fetch, AbortController)
  2. Type safety - Full TypeScript with types generated from OpenAPI spec
  3. Small bundle size - ~6.6 KB minified, ~2.5 KB gzipped
  4. Predictable error handling - Typed error classes for every failure mode
  5. Automatic retries - Exponential backoff with jitter for transient failures

Package Structure

packages/sdk/
β”œβ”€β”€ scripts/
β”‚   β”œβ”€β”€ generate-types.ts    # OpenAPI β†’ TypeScript generator
β”‚   └── manual-test.ts       # Manual testing script
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ index.ts             # Public exports
β”‚   β”œβ”€β”€ client.ts            # Luzia class
β”‚   β”œβ”€β”€ errors.ts            # Error classes and factories
β”‚   β”œβ”€β”€ retry.ts             # Retry logic with exponential backoff
β”‚   β”œβ”€β”€ types/
β”‚   β”‚   β”œβ”€β”€ index.ts         # Type re-exports and aliases
β”‚   β”‚   β”œβ”€β”€ generated.ts     # Auto-generated from OpenAPI
β”‚   β”‚   └── options.ts       # SDK-specific config types
β”‚   β”œβ”€β”€ resources/
β”‚   β”‚   β”œβ”€β”€ index.ts         # Resource exports
β”‚   β”‚   β”œβ”€β”€ exchanges.ts     # ExchangesResource
β”‚   β”‚   β”œβ”€β”€ markets.ts       # MarketsResource
β”‚   β”‚   └── tickers.ts       # TickersResource
β”‚   └── __tests__/           # Unit tests
β”œβ”€β”€ package.json
β”œβ”€β”€ tsconfig.json
β”œβ”€β”€ README.md                # Usage documentation
└── ARCHITECTURE.md          # This file

Type Generation (Single Source of Truth)

Types are generated from the OpenAPI specification to ensure the SDK stays in sync with the API contract.

Flow

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  apps/api/          β”‚
β”‚  openapi.yaml       β”‚  ← Single source of truth
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
          β”‚
          β”‚  bun run generate
          β”‚  (openapi-typescript)
          β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  src/types/         β”‚
β”‚  generated.ts       β”‚  ← Auto-generated types
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
          β”‚
          β”‚  Re-export with aliases
          β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  src/types/         β”‚
β”‚  index.ts           β”‚  ← Developer-friendly exports
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why This Approach?

  1. Catches breaking changes - If the API contract changes, TypeScript compilation fails
  2. No type drift - Types always match what the API actually returns
  3. Single maintenance point - Update OpenAPI spec, regenerate types

Regenerating Types

bun run generate

This reads /apps/api/openapi.yaml and outputs src/types/generated.ts.

Type Re-exports

src/types/index.ts re-exports generated types with friendly names:

// Re-export from generated (single source of truth)
export type Exchange = components['schemas']['Exchange']
export type Ticker = components['schemas']['Ticker']

// SDK-specific types (not in OpenAPI)
export type { LuziaOptions, RetryOptions } from './options.ts'

Client Architecture

Resource Pattern

The SDK uses a resource-based pattern similar to Stripe's SDK:

luzia.exchanges.list()
luzia.tickers.get('binance', 'BTC/USDT')
luzia.markets.list('binance', { quote: 'USDT' })

Each resource is a class that receives the client instance:

class TickersResource {
  constructor(private readonly client: Luzia) {}

  async get(exchange: string, symbol: string): Promise<Ticker> {
    return this.client.request(`/v1/ticker/${exchange}/${symbol}`)
  }
}

Request Flow

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Resource   β”‚ ──▢ β”‚    Client    β”‚ ──▢ β”‚    Retry     β”‚
β”‚   Method     β”‚     β”‚   request()  β”‚     β”‚   withRetry  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                                                  β”‚
                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”
                     β”‚   Response   β”‚ ◀── β”‚   _doRequest  β”‚
                     β”‚   or Error   β”‚     β”‚   (fetch)     β”‚
                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Custom Fetch

Users can inject a custom fetch implementation for testing or framework integration:

const client = new Luzia({
  apiKey: 'lz_xxx',
  fetch: customFetch, // For testing or React Query integration
})

Error Handling

Single Error Class with Code Discriminator

The SDK uses a single LuziaError class with a code property to distinguish error types:

class LuziaError extends Error {
  readonly status?: number    // HTTP status (e.g., 401, 404, 429)
  readonly code: ErrorCode    // Error category (e.g., 'auth', 'not_found')
  readonly correlationId?: string
  readonly retryAfter?: number  // For rate_limit errors
  readonly details?: Record<string, unknown>  // For validation errors
  readonly timeoutMs?: number   // For timeout errors
}

Error Codes

Code Description HTTP Status
auth Authentication failed 401
not_found Resource not found 404
validation Invalid request 400
rate_limit Rate limit exceeded 429
timeout Request timed out -
network Connection error -
server Server error 5xx
unknown Unknown error varies

Usage

try {
  await luzia.tickers.get('binance', 'BTC/USDT')
} catch (error) {
  if (error instanceof LuziaError) {
    switch (error.code) {
      case 'rate_limit':
        console.log(`Retry after ${error.retryAfter}s`)
        break
      case 'auth':
        console.log('Invalid API key')
        break
      case 'not_found':
        console.log('Resource not found')
        break
    }
  }
}

Retryable vs Non-Retryable

// Retryable codes
- 'rate_limit'  // 429
- 'network'     // Connection failures
- 'timeout'     // Request timeouts
- 'server'      // 5xx errors

// Non-retryable codes
- 'auth'        // 401
- 'validation'  // 400
- 'not_found'   // 404
- 'unknown'     // Other errors

Retry Logic

Exponential Backoff with Jitter

function calculateRetryDelay(attempt: number, options: RetryOptions): number {
  // Base delay doubles each attempt: 1s, 2s, 4s, 8s...
  let delay = options.initialDelayMs * options.backoffMultiplier ** attempt

  // Add jitter (Β±50%) to prevent thundering herd
  if (options.jitter) {
    delay = delay * (0.5 + Math.random())
  }

  // Cap at maximum delay
  return Math.min(delay, options.maxDelayMs)
}

Retry-After Header

For 429 responses, the SDK respects the Retry-After header:

if (rateLimitError) {
  delay = rateLimitError.retryAfter * 1000 + 100 // Add 100ms buffer
}

Rate Limit Tracking

The client parses rate limit headers and exposes them:

// After any request
const info = luzia.rateLimitInfo
// {
//   limit: 100,
//   remaining: 95,
//   reset: 1704067260,
//   dailyLimit: 5000,      // Free tier only
//   dailyRemaining: 4900,  // Free tier only
//   dailyReset: 1704153600 // Free tier only
// }

Bundle Size

The SDK is optimized for minimal bundle size:

Metric Size
Minified ~6.6 KB
Gzipped ~2.5 KB

How We Keep It Small

  1. Zero dependencies - No lodash, axios, etc.
  2. Native APIs only - fetch, AbortController, setTimeout
  3. Tree-shakeable exports - Import only what you need
  4. No polyfills - Requires Node 18+ or modern browsers

Extending the SDK

Adding a New Resource

  1. Create src/resources/newresource.ts:
import type { Luzia } from '../client.ts'
import type { NewType } from '../types/index.ts'

export class NewResource {
  constructor(private readonly client: Luzia) {}

  async get(id: string): Promise<NewType> {
    return this.client.request(`/v1/new/${id}`)
  }
}
  1. Add to client in src/client.ts:
import { NewResource } from './resources/newresource.ts'

class Luzia {
  readonly newResource: NewResource

  constructor(options: LuziaOptions) {
    this.newResource = new NewResource(this)
  }
}
  1. Export from src/resources/index.ts and src/index.ts

Adding New Types

  1. Update /apps/api/openapi.yaml with new schema
  2. Run bun run generate to regenerate types
  3. Add re-export in src/types/index.ts if needed

Testing

Unit Tests

bun test

Tests use mocked fetch to verify:

  • Request URL construction
  • Header injection
  • Error handling
  • Retry behavior

Manual Testing

# Start API server
bun dev:api

# Run manual test
bun run scripts/manual-test.ts lz_your_api_key

Dependencies

Runtime Dependencies

None - Zero runtime dependencies.

Dev Dependencies

Package Purpose
openapi-typescript Generate types from OpenAPI
typescript Type checking
@types/bun Bun runtime types