Skip to content

Latest commit

 

History

History
582 lines (437 loc) · 13.1 KB

File metadata and controls

582 lines (437 loc) · 13.1 KB

Trembita Learning Guide

Learn to build type-safe, error-handling HTTP clients with trembita — from basics to advanced patterns.

Table of Contents

  1. Core Concepts
  2. Getting Started
  3. The Result Pattern
  4. Building a Client
  5. Error Handling
  6. Advanced Features
  7. Testing
  8. Common Patterns

Core Concepts

Trembita enforces three principles:

1. Functional API (no classes)

Instead of new Client(), you call factory functions:

const result = createTrembita({ endpoint: '...' });

2. Result<T, E> for errors (no exceptions)

Every operation returns either { ok: true, value } or { ok: false, error }. No try/catch for normal HTTP outcomes:

const response = await client.request({ path: '/' });
if (!response.ok) {
  // handle response.error.kind
}

3. Stdlib-first (zero dependencies)

Uses only fetch and URL from Node/browser. Inject your own fetchImpl for testing—no global mocking.


Getting Started

Install

npm install trembita

Minimal example (1 minute)

import { createTrembita, HTTP_OK } from 'trembita';

// Create a client bound to a base URL
const api = createTrembita({ endpoint: 'https://api.github.com' });
if (!api.ok) throw api.error; // Init error

// Make a request
const repos = await api.value.request({
  path: '/users/octocat/repos',
  expectedCodes: [HTTP_OK]
});

// Handle the result
if (repos.ok) {
  console.log(repos.value); // parsed JSON
} else {
  console.error(repos.error.kind); // e.g. 'fetch_failed'
}

3 key steps:

  1. createTrembita() → validates options, returns Result
  2. .request()Promise<Result<unknown, TrembitaRequestError>>
  3. Check .ok to narrow the type

The Result Pattern

What is Result<T, E>?

A simple discriminated union that forces you to handle both success and failure:

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

Why? TypeScript narrows the type when you check .ok:

const r = await client.request({ path: '/' });

if (r.ok) {
  // TypeScript knows r.value exists
  console.log(r.value.length);
} else {
  // TypeScript knows r.error exists
  if (r.error.kind === 'fetch_failed') {
    console.error('Network error:', r.error.cause);
  }
}

Extracting values safely

// Option 1: if/else
if (result.ok) {
  doSomething(result.value);
} else {
  console.error(result.error.kind);
}

// Option 2: switch on error.kind
switch (result.error.kind) {
  case 'unexpected_status':
    console.error('HTTP', result.error.statusCode);
    break;
  case 'fetch_failed':
    console.error('Network:', result.error.cause);
    break;
  // TypeScript requires all cases or a default
}

// Option 3: throw if you must
if (!result.ok) throw new Error(result.error.kind);
const data = result.value;

Common Error Kinds

kind Meaning Example
missing_endpoint No endpoint in options createTrembita({})
endpoint_invalid_url Endpoint URL is malformed endpoint: 'not a url'
invalid_request_options Missing path or invalid query request({ query: 123 })
fetch_failed Network error (DNS, timeout) Connection reset
timeout Request exceeded timeoutMs Exceeded 30s
invalid_json Response body isn't JSON Response is HTML
unexpected_status Status not in expectedCodes Got 404, expected 200

Building a Client

Step 1: Create and validate

import { createTrembita, HTTP_OK, HTTP_CREATED } from 'trembita';

const githubApi = createTrembita({
  endpoint: 'https://api.github.com'
});

if (!githubApi.ok) {
  throw new Error(`Bad endpoint: ${githubApi.error.kind}`);
}

const client = githubApi.value; // now safe to use

Step 2: Define request helpers

// Encapsulate common patterns
async function getUser(username: string) {
  return client.request({
    path: `/users/${username}`,
    expectedCodes: [HTTP_OK]
  });
}

async function createIssue(repo: string, title: string, body: string) {
  return client.request({
    path: `/repos/octocat/${repo}/issues`,
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.GH_TOKEN}` },
    body: { title, body },
    expectedCodes: [HTTP_CREATED]
  });
}

Step 3: Call and handle

const user = await getUser('octocat');

if (user.ok) {
  console.log('Login:', user.value.login);
} else if (
  user.error.kind === 'unexpected_status' &&
  user.error.statusCode === 404
) {
  console.log('User not found');
} else {
  console.error('Request failed:', user.error.kind);
}

Step 4: Type the response (optional)

type GitHubUser = { login: string; id: number };

async function getUser(
  username: string
): Promise<Result<GitHubUser, TrembitaRequestError>> {
  const result = await client.request({
    path: `/users/${username}`,
    expectedCodes: [HTTP_OK]
  });

  // Optionally validate with Standard Schema (see Advanced Features)
  return result;
}

Error Handling

Health checks and monitoring

When you need raw status codes:

const health = await client.client({ path: '/health' });

if (health.ok) {
  const { statusCode, body } = health.value;
  if (statusCode === 200) {
    console.log('Service healthy');
  } else {
    console.log('Degraded:', body);
  }
} else {
  console.error('Health check failed:', health.error.kind);
}

Retry logic

Use createRetryingFetch to wrap fetch with exponential backoff:

import { createRetryingFetch } from 'trembita';

const retryingFetch = createRetryingFetch({
  initialDelayMs: 100,
  maxAttempts: 3,
  shouldRetry: (statusCode) => statusCode >= 500 // Retry 5xx only
});

const api = createTrembita({
  endpoint: 'https://api.example.com',
  fetchImpl: retryingFetch
});

Circuit breaker pattern

Prevent cascading failures:

const api = createTrembita({
  endpoint: 'https://api.example.com',
  circuitBreaker: {
    failureThreshold: 5, // open after 5 failures
    cooldownMs: 30_000 // retry after 30s
  }
});

// Later, when circuit is open:
const response = await api.value.request({ path: '/' });
if (!response.ok && response.error.kind === 'circuit_open') {
  // Service is temporarily unavailable
  console.log('Backing off...');
}

Logging

Enable structured logging:

const api = createTrembita({
  endpoint: 'https://api.example.com',
  log: {
    trace: (msg, meta) => console.log('[TRACE]', msg, meta),
    debug: (msg, meta) => console.log('[DEBUG]', msg, meta),
    info: (msg, meta) => console.log('[INFO]', msg, meta),
    warn: (msg, meta) => console.warn('[WARN]', msg, meta),
    error: (msg, meta) => console.error('[ERROR]', msg, meta)
  }
});

Logs are automatically redacted (auth headers, cookies, API keys hidden).


Advanced Features

1. Standard Schema Validation

Validate response bodies at parse time:

import { requestWithStandardSchema, validateStandardSchema } from 'trembita';
import { z } from 'zod';

const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email()
});

// Validate response body
const result = await requestWithStandardSchema(client, {
  path: '/users/123',
  schema: userSchema,
  expectedCodes: [HTTP_OK]
});

if (result.ok) {
  // result.value is typed as Zod schema type
  console.log(result.value.email);
} else if (result.error.kind === 'validation_failed') {
  console.error('Invalid response:', result.error.issues);
}

2. W3C Trace Context (Distributed Tracing)

Build OpenTelemetry headers:

import { traceContextHeaders } from 'trembita';

const headers = traceContextHeaders({
  traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
  spanId: '00f067aa0ba902b7',
  traceFlags: '01',
  traceState: 'congo=t61rcZ94t8w25c'
});

const response = await client.request({
  path: '/api/resource',
  headers // propagates trace to downstream service
});

3. OpenAPI paths (optional @trembita/openapi)

Generate type-safe paths from OpenAPI specs:

import { expandOpenapiPath } from '@trembita/openapi';

// paths comes from `openapi-typescript`
// type paths = { '/users/{id}': {...}, ... }

const expanded = expandOpenapiPath(paths, '/users/{id}', { id: '123' });
// → '/users/123'

if (expanded.ok) {
  const result = await client.request({
    path: expanded.value, // type-safe!
    expectedCodes: [HTTP_OK]
  });
}

Testing

Inject a mock fetch

No global mocking needed:

import { vi } from 'vitest';
import { createTrembita, HTTP_OK } from 'trembita';

const mockFetch = vi.fn(() =>
  Promise.resolve(new Response(JSON.stringify({ id: 1 }), { status: 200 }))
);

const api = createTrembita({
  endpoint: 'https://api.test.com',
  fetchImpl: mockFetch
});

const result = await api.value.request({
  path: '/items',
  expectedCodes: [HTTP_OK]
});

expect(result.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
  'https://api.test.com/items',
  expect.any(Object)
);

Test error paths

const mockFetch = vi.fn(() => Promise.reject(new Error('DNS failure')));

const api = createTrembita({
  endpoint: 'https://api.test.com',
  fetchImpl: mockFetch
});

const result = await api.value.request({ path: '/' });

expect(result.ok).toBe(false);
expect(result.error.kind).toBe('fetch_failed');

Test expected status codes

const mockFetch = vi.fn(() =>
  Promise.resolve(new Response('Not found', { status: 404 }))
);

const result = await api.value.request({
  path: '/missing',
  expectedCodes: [200, 201]
});

expect(result.ok).toBe(false);
expect(result.error.kind).toBe('unexpected_status');
expect(result.error.statusCode).toBe(404);

Common Patterns

Pattern 1: Typed REST client

import { createTrembita, HTTP_OK, TrembitaRequestError } from 'trembita';

// Define domain types
type User = { id: number; name: string };
type CreateUserPayload = Omit<User, 'id'>;

// Define Result type for clarity
type ApiResult<T> = Promise<Result<T, TrembitaRequestError>>;

class UserApi {
  constructor(private client: TrembitaClient) {}

  getUser(id: number): ApiResult<User> {
    return this.client.request({
      path: `/users/${id}`,
      expectedCodes: [HTTP_OK]
    });
  }

  createUser(payload: CreateUserPayload): ApiResult<User> {
    return this.client.request({
      path: '/users',
      method: 'POST',
      body: payload,
      expectedCodes: [HTTP_CREATED]
    });
  }
}

// Usage
const api = createTrembita({ endpoint: 'https://api.example.com' });
if (!api.ok) throw api.error;

const users = new UserApi(api.value);
const user = await users.getUser(1);

Pattern 2: Fallback cascade

async function fetchWithFallback() {
  let result = await primaryApi.request({ path: '/data' });

  if (!result.ok && result.error.kind === 'fetch_failed') {
    // Primary API is down, try backup
    result = await backupApi.request({ path: '/data' });
  }

  return result;
}

Pattern 3: Request middleware

async function withAuth(path: string, token: string) {
  return client.request({
    path,
    headers: {
      Authorization: `Bearer ${token}`
    }
  });
}

// Usage
const data = await withAuth('/protected', process.env.API_TOKEN);

Pattern 4: Batch requests

async function fetchUsers(ids: number[]) {
  const results = await Promise.all(
    ids.map((id) =>
      client.request({
        path: `/users/${id}`,
        expectedCodes: [HTTP_OK, 404]
      })
    )
  );

  const users = results.filter((r) => r.ok).map((r) => r.value);

  const notFound = results
    .filter((r) => !r.ok && r.error.kind === 'unexpected_status')
    .map((r, i) => ids[i]);

  return { users, notFound };
}

Next Steps


FAQ

Q: Why Result instead of exceptions?
A: Exceptions hide error cases. Result forces you to handle both paths at compile time, making bugs rarer.

Q: Can I use this in the browser?
A: Yes—it's ESM and works with any bundler (Vite, webpack, esbuild). Global fetch and URL required.

Q: Why no middleware?
A: Simple functions are more composable. Wrap fetchImpl for retries, or compose request helpers for auth.

Q: Can I use this with REST frameworks?
A: Yes. trembita is a client, not a server. Use with Next.js, Express, Fastify, etc. to consume APIs.

Q: How do I add custom logic between requests?
A: Inject a custom fetchImpl that wraps the real fetch, or compose request helpers with shared logic.