From andercore-toolkit-services
HTTP clients with auth, resilience (retry, circuit breaker, bulkhead), observability. Use when calling external APIs, configuring HTTP clients, implementing resilience.
npx claudepluginhub andercore-labs/claudes-kitchen --plugin andercore-toolkit-servicesThis skill uses the workspace's default tool permissions.
| Need | Pattern | File |
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Generates original PNG/PDF visual art via design philosophy manifestos for posters, graphics, and static designs on user request.
| Need | Pattern | File |
|---|---|---|
| Register client | HttpClientModule.forRoot() | app.module.ts:15 |
| Inject | @InjectHttpClient('name') | service.ts:10 |
| OAuth | authentication: { type: 'oauth', ... } | app.module.ts:25 |
| Retry | retryPolicy: { enabled: true, ... } | app.module.ts:35 |
| Circuit breaker | circuitBreaker: { enabled: true, ... } | app.module.ts:42 |
External APIs | HTTP requests | resilience patterns | API clients | authentication
app.module.ts:
import { HttpClientModule } from '@andercore/toolkit'
@Module({
imports: [
HttpClientModule.forRoot({
clients: [{
name: 'user-api',
baseURL: 'https://api.example.com',
timeout: 30000,
tracing: true,
metrics: true,
headers: {
'User-Agent': 'MyApp/1.0.0',
'Accept': 'application/json'
},
authentication: {
type: 'oauth',
tokenUrl: 'https://auth.example.com/token',
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
scope: 'read:users write:users'
},
retryPolicy: {
enabled: true,
maxAttempts: 3,
backoff: {
type: 'exponential',
initialDelay: 1000,
maxDelay: 30000,
multiplier: 2
},
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
retryableErrors: ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND']
},
circuitBreaker: {
enabled: true,
failureThreshold: 50,
successThreshold: 2,
timeout: 60000,
halfOpenMaxAttempts: 3
},
bulkhead: {
enabled: true,
maxConcurrent: 10,
maxQueue: 100,
queueTimeout: 30000
},
keepAlive: true,
maxSockets: 100,
keepAliveTimeout: 30000
}],
global: true
})
]
})
export class AppModule {}
| Option | Required | Default | Purpose |
|---|---|---|---|
| name | ✓ | - | Unique client identifier |
| baseURL | ✓ | - | API base URL |
| timeout | ✗ | 30000 | Request timeout (ms) |
| tracing | ✗ | false | Enable OTel tracing |
| metrics | ✗ | false | Enable metrics collection |
| headers | ✗ | {} | Default headers |
| authentication | ✗ | - | Auth strategy |
| retryPolicy | ✗ | - | Retry configuration |
| circuitBreaker | ✗ | - | Circuit breaker config |
| bulkhead | ✗ | - | Concurrency control |
| keepAlive | ✗ | true | TCP keepalive |
| maxSockets | ✗ | Infinity | Max concurrent sockets |
import { Injectable } from '@nestjs/common'
import { InjectHttpClient, HttpClient } from '@andercore/toolkit'
import { Task } from 'true-myth'
@Injectable()
export class UserApiService {
constructor(@InjectHttpClient('user-api') private http: HttpClient) {}
async getUser(id: string): Task<User, ApiError> {
return this.http.get(`/users/${id}`)
.map(response => response.data)
.mapRejected(this.mapError)
}
async createUser(data: CreateUserDto): Task<User, ApiError> {
return this.http.post('/users', data)
.map(response => response.data)
.mapRejected(this.mapError)
}
async updateUser(id: string, data: UpdateUserDto): Task<User, ApiError> {
return this.http.put(`/users/${id}`, data)
.map(response => response.data)
.mapRejected(this.mapError)
}
async deleteUser(id: string): Task<void, ApiError> {
return this.http.delete(`/users/${id}`)
.map(() => undefined)
.mapRejected(this.mapError)
}
private mapError(error: unknown): ApiError {
if (error instanceof HttpResponseError) {
return new ApiError(error.status, error.statusText, error.data)
}
return new ApiError(500, 'Internal error', error)
}
}
| Method | Signature | Returns |
|---|---|---|
| get | get<T>(url, config?) | Task<HttpResponse<T>, Error> |
| post | post<T>(url, data?, config?) | Task<HttpResponse<T>, Error> |
| put | put<T>(url, data?, config?) | Task<HttpResponse<T>, Error> |
| patch | patch<T>(url, data?, config?) | Task<HttpResponse<T>, Error> |
| delete | delete<T>(url, config?) | Task<HttpResponse<T>, Error> |
| head | head(url, config?) | Task<HttpResponse<void>, Error> |
| options | options(url, config?) | Task<HttpResponse<void>, Error> |
| request | request<T>(config) | Task<HttpResponse<T>, Error> |
const response = await this.http.get('/users', {
params: { page: 1, limit: 10 },
headers: { 'X-Custom-Header': 'value' },
timeout: 5000,
signal: abortController.signal
})
See auth-strategies.md for:
Quick OAuth:
authentication: {
type: 'oauth',
tokenUrl: 'https://auth.example.com/token',
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
scope: 'read:users write:users',
grantType: 'client_credentials',
contentType: 'application/x-www-form-urlencoded'
}
Features:
See resilience.md for:
Quick retry:
retryPolicy: {
enabled: true,
maxAttempts: 3,
backoff: {
type: 'exponential',
initialDelay: 1000,
maxDelay: 30000,
multiplier: 2
},
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
retryableErrors: ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND']
}
Quick circuit breaker:
circuitBreaker: {
enabled: true,
failureThreshold: 50, // % failures → OPEN
successThreshold: 2, // Successes → CLOSED
timeout: 60000, // OPEN → HALF-OPEN delay (ms)
halfOpenMaxAttempts: 3
}
Error hierarchy:
HttpClientError
├── HttpRequestError (request failed before sending)
├── HttpResponseError (response received with error status)
│ ├── status: number
│ ├── statusText: string
│ └── data: unknown
├── HttpTimeoutError (request timed out)
├── HttpNetworkError (network failure)
├── CircuitBreakerOpenError (circuit breaker open)
├── BulkheadRejectedError (bulkhead queue full)
└── RetryExhaustedError (max retries exceeded)
@Injectable()
export class ResilientUserService {
constructor(@InjectHttpClient('user-api') private http: HttpClient) {}
async getUser(id: string): Task<User, ServiceError> {
return this.http.get(`/users/${id}`)
.map(response => response.data)
.mapRejected(error => {
if (error instanceof HttpResponseError) {
if (error.status === 404) {
return new UserNotFoundError(id)
}
if (error.status === 429) {
return new RateLimitError('Too many requests')
}
return new ApiError(error.status, error.statusText)
}
if (error instanceof CircuitBreakerOpenError) {
return new ServiceUnavailableError('User service temporarily unavailable')
}
if (error instanceof RetryExhaustedError) {
return new ServiceTimeoutError('User service timeout after retries')
}
if (error instanceof BulkheadRejectedError) {
return new ServiceOverloadedError('Too many concurrent requests')
}
return new UnknownServiceError(error)
})
}
}
| Status | Error Type | Recovery |
|---|---|---|
| 400 | HttpResponseError | Validate request |
| 401 | HttpResponseError | Refresh auth token |
| 403 | HttpResponseError | Check permissions |
| 404 | HttpResponseError | Resource not found |
| 408 | HttpResponseError | Retry (transient) |
| 429 | HttpResponseError | Retry with backoff |
| 500 | HttpResponseError | Retry (transient) |
| 502 | HttpResponseError | Retry (transient) |
| 503 | HttpResponseError | Circuit breaker |
| 504 | HttpResponseError | Retry (transient) |
Collected automatically:
http.client.request.duration (histogram, seconds)http.client.request.body.size (histogram, bytes)http.client.response.body.size (histogram, bytes)http_client.circuit_breaker.state (gauge)http_client.bulkhead.concurrent (gauge)http_client.bulkhead.queue.size (gauge)http_client.bulkhead.rejected.total (counter)http_client.retry.attempts (counter)http_client.oauth.token.fetch (counter)http_client.oauth.token.fetch.duration (histogram)http_client.oauth.token.cache_hit (counter)http_client.oauth.token.refresh (counter)http_client.oauth.token.error (counter)Attributes (SemConv):
http.request.methodhttp.response.status_codeerror.typeclient.nameserver.addressurl.pathFor business-level tracking:
// src/observability/payment/payment-api-metrics.adapter.ts
@Injectable()
export class PaymentApiMetricsAdapter {
private apiCallCounter: Counter
constructor() {
const meter = metrics.getMeter('payment-api')
this.apiCallCounter = meter.createCounter('payment.api.calls.total')
}
recordApiCall(tier: 'free' | 'premium', endpoint: string) {
this.apiCallCounter.add(1, {
'customer.tier': tier,
'api.endpoint': endpoint
})
}
}
Span attributes (SemConv):
http.request.methodurl.fullserver.address, server.porthttp.response.status_codeerror.type (on failures)http.client.retry.counthttp.client.circuit_breaker.stateCreate without module registration:
import { HttpClientModule } from '@andercore/toolkit'
const client = HttpClientModule.createStandaloneClient({
name: 'temp-client',
baseURL: 'https://api.example.com',
timeout: 10000,
tracing: true,
metrics: true
})
const response = await client.get('/data')
→ typescript-services:test-code-recipe for patterns
Mock HTTP client:
const mockHttp = {
get: jest.fn().mockReturnValue(Task.ok({ data: mockUser })),
post: jest.fn().mockReturnValue(Task.ok({ data: mockUser }))
}
CHECK:
- [ ] HttpClientModule.forRoot() in app.module?
- [ ] @InjectHttpClient(name) for injection?
- [ ] Unique client names?
- [ ] Authentication configured (OAuth preferred)?
- [ ] Retry policy enabled with exponential backoff?
- [ ] Circuit breaker enabled for unreliable services?
- [ ] Bulkhead configured for high-traffic clients?
- [ ] tracing: true for observability?
- [ ] metrics: true for monitoring?
- [ ] Error handling with typed checks (instanceof)?
- [ ] NO direct axios imports?
- [ ] Timeout configured (default 30s)?
VIOLATIONS (CRITICAL):
- import axios from 'axios' → CRITICAL
- No retry on transient failures (408, 429, 5xx) → HIGH
- No circuit breaker for external APIs → HIGH
- No bulkhead for high-traffic APIs → MEDIUM
- Generic catch (error) without type checks → MEDIUM
- tracing: false (no observability) → MEDIUM
- No timeout configured → MEDIUM
PASS → DONE | FAIL → cite violations with file:line
async getAllUsers(): Task<User[], ApiError> {
const users: User[] = []
let page = 1
let hasMore = true
while (hasMore) {
const response = await this.http.get('/users', {
params: { page, limit: 100 }
})
users.push(...response.data.items)
hasMore = response.data.hasMore
page++
}
return Task.ok(users)
}
async searchUsers(query: string, signal: AbortSignal): Task<User[], ApiError> {
return this.http.get('/users/search', {
params: { q: query },
signal
}).map(response => response.data)
}
// Usage
const controller = new AbortController()
const searchTask = userService.searchUsers('john', controller.signal)
// Cancel after 5s
setTimeout(() => controller.abort(), 5000)
async getUserAsAdmin(id: string): Task<User, ApiError> {
return this.http.get(`/users/${id}`, {
headers: {
'X-Admin-Token': process.env.ADMIN_TOKEN,
'X-Requested-By': 'admin-panel'
}
}).map(response => response.data)
}
HTTP clients = outbound adapters:
✗ NO HTTP client injection in:
→ See backend-services:hexagonal-architecture-recipe for adapter patterns