What it is · Feature tour · Install · Quickstart · Language status · Examples · Platform
Stop writing the same retry logic over and over.
@smooai/fetchis a drop-infetchthat survives the reality of network failures — exponential backoff with jitter, timeouts,Retry-After-aware rate-limit handling, circuit breaking, lifecycle hooks, and typed responses — with native ports in five languages: TypeScript, Python, Rust, Go, and .NET. Same semantics everywhere; each port built idiomatically for its ecosystem.
Traditional fetch gives you the request, but leaves you to handle the reality of flaky APIs, slow endpoints, and rate limits. @smooai/fetch handles them by default.
One resilient HTTP client, ported natively to five languages. Every port carries the same core behaviors — verified against the source of each port, not aspirational:
- 🔄 Smart retries — exponential backoff with jitter to prevent thundering herds; retries only on network errors and retryable statuses
- ⏱️ Automatic timeouts — never hang indefinitely on slow endpoints (10s default, configurable per request)
- 🚦 Rate-limit respect — reads
Retry-Afterheaders and waits exactly what the server asked, plus a client-side sliding-window rate limiter - 🔌 Circuit breaking — stop hammering services that are clearly down
- 🔗 Lifecycle hooks — pre-request / post-response hooks for auth, logging, and metrics
- 🔑 Async auth token provider — register a token callback once; every request picks up a fresh token
- 📡 W3C trace-context propagation —
traceparentheaders injected automatically; OpenTelemetry is an optional integration in every port, never a hard dependency - 🎯 Typed responses — response typing and validation in every language, with mechanics that differ per ecosystem (see the honest matrix)
Each capability in a few lines of real, current API — snippets are verified against src/ and the language ports, not pseudocode.
| Capability | What you get | |
|---|---|---|
| 🔄 | Smart retries | Backoff + jitter, only on errors worth retrying |
| 🚦 | Rate-limit respect | Retry-After honored to the second, in all five ports |
| 🔌 | Circuit breaking | Fail fast when a dependency is down |
| 🎯 | Typed responses | Schema-validated data, typed end to end |
| 🔗 | Hooks + auth | One place for tokens, logging, and response policy |
| 📡 | Trace propagation | traceparent on every request, optional OpenTelemetry |
import fetch from '@smooai/fetch';
// This won't crash if the API is temporarily down
const response = await fetch('https://flaky-api.com/data');
// Behind the scenes:
// Attempt 1: 500 error — waits ~500ms (jittered)
// Attempt 2: 503 error — waits ~1000ms
// Attempt 3: 200 success ✅Defaults (TypeScript): 2 automatic retries, exponential backoff starting at 500ms with factor 2, jitter to prevent thundering herds, and retries only on network errors or retryable HTTP statuses.
const response = await fetch('https://api.github.com/user/repos');
// If GitHub says "slow down":
// - Sees 429 + Retry-After: 60
// - Automatically waits 60 seconds
// - Retries and succeedsAll five ports parse Retry-After and wait what the server asked instead of the default backoff. A client-side sliding-window rate limiter (withRateLimit(100, 60000)) keeps you from hitting the ceiling in the first place.
import { FetchBuilder } from '@smooai/fetch';
const criticalAPI = new FetchBuilder()
.withCircuitBreaker({
failureRateThreshold: 50, // open when ≥50% of calls fail…
slidingWindowSize: 10, // …across the last 10 calls
openStateDelayMs: 30000, // stay open 30s, then trial a half-open call
})
.build();
try {
await criticalAPI('https://payment-processor.com/charge');
} catch (error) {
// Circuit is open — service is down. Show fallback UI immediately.
}import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
});
const response = await fetch('https://api.example.com/user', {
options: { schema: UserSchema },
});
// response.data is fully typed as { id: string; email: string }
// No more runtime surprises in productionIn TypeScript, schema accepts any Standard Schema validator — Zod, Valibot, ArkType. The other ports type responses with their ecosystem's native tools; the language matrix says exactly which.
const api = new FetchBuilder()
.withAuthTokenProvider(async () => await tokenStore.getFreshToken(), 'Bearer')
.withHooks({
postResponseError: (url, init, error) => {
if (error.response?.status === 401) {
refreshToken(); // Token expired — refresh and retry
}
return error;
},
})
.build();Every port has both seams: an async auth-token provider (fresh token per request, no client rebuild) and pre-request / post-response hooks.
Every port injects a W3C traceparent header when a trace is active, so your HTTP calls join the distributed trace automatically. OpenTelemetry is an optional peer/feature in each language — the client works identically without it installed.
// With @opentelemetry/api installed and a span active:
await api('https://api.example.com/users/123');
// → headers: { traceparent: '00-<trace-id>-<span-id>-01' }
// Without it: same request, no traceparent, zero errors.%%{init: {'theme':'base','themeVariables':{
'background':'#020618','primaryColor':'#0b1426','primaryTextColor':'#e6edf6','primaryBorderColor':'#2b3a52',
'lineColor':'#7c8aa0','secondaryColor':'#0b1426','tertiaryColor':'#0b1426','fontFamily':'ui-sans-serif, system-ui, sans-serif',
'clusterBkg':'#0b1426','clusterBorder':'#22304a'}}}%%
flowchart LR
REQ["request"] --> PRE["pre-request hooks<br/>auth token · traceparent"]
PRE --> RL["rate limiter<br/>sliding window"]
RL --> CB["circuit breaker"]
CB --> RETRY
subgraph RETRY["retry loop — backoff + jitter, Retry-After aware"]
T["timeout"] --> HTTP["HTTP call"]
end
RETRY --> POST["post-response hooks"]
POST --> VAL["typed response<br/>schema / serde / generics"]
classDef warm fill:#f49f0a,stroke:#ff6b6c,color:#1a0f00;
classDef teal fill:#00a6a6,stroke:#00c2c2,color:#011;
class RETRY warm
class PRE,VAL teal
| Language | Package | Install |
|---|---|---|
| TypeScript | @smooai/fetch |
pnpm add @smooai/fetch |
| Python | smooai-fetch |
pip install smooai-fetch |
| Rust | smooai-fetch |
cargo add smooai-fetch |
| Go | github.com/SmooAI/fetch/go/fetch/v3 |
go get github.com/SmooAI/fetch/go/fetch/v3 |
| .NET | SmooAI.Fetch |
dotnet add package SmooAI.Fetch |
Go note: the module path carries the
/v3major suffix Go requires above v1, so thego/fetch/v3.xtags resolve. The import path isgithub.com/SmooAI/fetch/go/fetch/v3; the package identifier is stillfetch. Tags minted before this change (throughgo/fetch/v3.4.0) point at commits whosego.modlacked the suffix and will not resolve — usev3.4.1or later.
Language-specific source lives in src/ (TypeScript), python/, rust/, go/, and dotnet/.
It's just fetch, but resilient — retries, timeout, and Retry-After handling are on by default in every port.
TypeScript (full docs)
import fetch from '@smooai/fetch';
const response = await fetch('https://api.example.com/users/123');
const user = await response.json();Python (python/)
from smooai_fetch import FetchBuilder
builder = FetchBuilder().with_timeout(5000).with_retry()
response = await builder.fetch("https://api.example.com/users/123")Rust (rust/fetch/)
use smooai_fetch::fetch;
use smooai_fetch::types::RequestInit;
let response = fetch::<serde_json::Value>("https://api.example.com/users/123", RequestInit::default()).await?;Go (go/fetch/)
client := fetch.NewClientBuilder().
WithTimeout(10 * time.Second).
WithRetry(&fetch.DefaultRetryOptions).
Build()
resp, err := fetch.Get[User](ctx, client, "https://api.example.com/users/1", nil).NET (dotnet/SmooAI.Fetch/)
var fetch = SmooFetch.Create(options =>
{
options.BaseUrl = "https://api.example.com";
options.RetryPolicy = RetryPolicy.ExponentialBackoff(maxRetries: 3);
});
var user = await fetch.GetAsync<User>("/users/me");// Node.js
import fetch from '@smooai/fetch';
// Browser — same API, different entry point
import fetch from '@smooai/fetch/browser';
const response = await fetch('/api/checkout', {
method: 'POST',
body: { items: cart },
});Every port carries the shared core: retries with backoff + jitter, Retry-After handling, timeouts, a sliding-window rate limiter, a circuit breaker, lifecycle hooks, an async auth-token provider, and W3C traceparent propagation. The mechanics differ per ecosystem — same semantics, not byte-identical behavior:
| Language | Response typing / validation | Resilience engine | HTTP stack |
|---|---|---|---|
| TypeScript | Any Standard Schema validator (Zod, …) | mollitia | native fetch |
| Python | Pydantic models via with_schema(...) |
implemented in-package | httpx |
| Rust | serde — fetch::<T> deserializes into your type |
implemented in-crate | reqwest |
| Go | Generics — fetch.Get[User](...) decodes into your struct, plus an optional RequestOptions.Validate hook returning SchemaValidationError |
implemented in-package | net/http |
| .NET | System.Text.Json — GetAsync<T> / PostAsync<TReq, TRes> (no pluggable validator) |
Polly + System.Threading.RateLimiting |
HttpClient / IHttpClientFactory |
Where a port leans on a battle-tested ecosystem library (mollitia, Polly), it says so above; the others implement retry/breaker/rate-limit logic natively, with each port's own test suite covering the shared behaviors.
| Language | What it logs about a request | Redaction |
|---|---|---|
| TypeScript | method, host, path, query string, headers, request body, and the URL in the message | full — headers, query, URL and body |
| Rust | method and URL, on one tracing::debug! event |
URL only (userinfo password + query params) |
| Python | nothing | n/a — no logging sink |
| Go | nothing | n/a — no logging sink |
| .NET | nothing (an ILogger<SmooFetch> is held for DI but never called) |
n/a — no logging sink |
This is not a parity gap. Redaction exists in exactly the two ports that have something to redact. Adding a scrubber to Python, Go or .NET would be code no call site reaches — which reads as a guarantee while guaranteeing nothing. The shared cases in spec/redaction-corpus.json are loaded by the TypeScript and Rust suites, and that file states the rule for anyone extending it: if a logging sink is ever added to another port, wire it to this corpus in the same PR.
Out of the box, @smooai/fetch is configured for the real world:
Retry strategy — 2 automatic retries, exponential backoff (500ms → 1s → 2s), jitter to prevent thundering herds, and retries only on network errors or retryable responses.
Timeout protection — 10-second default timeout, configurable per request, so requests never hang indefinitely.
Connect timeout (opt-in) — connectTimeoutMs / withConnectTimeout bounds only the connection-establishment phase, in all five ports. A black-holed connect then fails in ~that window and retry lands on a live endpoint, instead of burning the whole-request timeout on a dead one; slow-but-alive handlers are unaffected. Off by default. In TypeScript it needs the optional peer dependency undici and applies to Node only.
Rate-limit handling — respects Retry-After headers and backs off automatically on 429 responses.
const primaryAPI = new FetchBuilder().withCircuitBreaker({ failureRateThreshold: 50 }).build();
const fallbackAPI = new FetchBuilder().withTimeout(2000).build();
async function getWeather(city: string) {
try {
return await primaryAPI(`https://api1.weather.com/${city}`);
} catch (error) {
console.warn('Primary weather API failed, using fallback');
return await fallbackAPI(`https://api2.weather.com/${city}`);
}
}@smooai/fetch works with @smooai/logger for complete observability across distributed systems.
import fetch, { FetchBuilder } from '@smooai/fetch';
import { AwsServerLogger } from '@smooai/logger/AwsServerLogger';
const logger = new AwsServerLogger({ name: 'APIClient' });
const api = new FetchBuilder()
.withLogger(logger) // That's it
.build();
// In Service A
logger.info('Starting user flow'); // Correlation ID: abc-123
const user = await api('/users/123'); // Correlation ID sent as header
// In Service B, the correlation ID is automatically extracted and logs are linked.Everything this client logs about a request — headers, query string, URL and body — is scrubbed of credential-bearing keys first, so an OAuth token exchange or a Bearer header does not land in CloudWatch in plaintext. Redaction is always on and applies only to the logged copy; the request on the wire is untouched.
A key is redacted when its normalized form (lowercased, -/_/. stripped) contains secret, password, passwd, token, apikey, authorization, credential, privatekey, assertion, cookie, session or signature, or equals auth, code, pwd or sig. The cases are pinned in spec/redaction-corpus.json, which both the TypeScript and Rust test suites load. client_id is deliberately not redacted — it is public in OAuth and load-bearing when debugging.
The Rust client redacts the URL it logs (userinfo password + query params); the Python, Go and .NET clients log nothing about a request, so they have nothing to redact.
When something goes wrong, you have the complete story — initial request, each retry attempt, circuit-breaker state changes, and the final error with a full stack trace:
try {
const response = await api('/flaky-endpoint');
} catch (error) {
logger.error('Request failed after retries', error);
}
// In your logs:
// {
// "correlationId": "abc-123",
// "message": "Request failed after retries",
// "error": { "attempts": 3, "lastError": "TimeoutError", "circuitState": "open" },
// "callerContext": { "stack": ["/src/services/UserService.ts:42:16"] }
// }- Basic usage
- FetchBuilder pattern
- Retry
- Timeout
- Rate limit
- Schema validation
- Lifecycle hooks
- Predefined authentication
- Error handling
import fetch from '@smooai/fetch';
// Simple GET request
const response = await fetch('https://api.example.com/data');
// POST request with JSON body and options
const response = await fetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: {
key: 'value',
},
options: {
timeout: {
timeoutMs: 5000,
},
retry: {
attempts: 3,
},
},
});The FetchBuilder provides a fluent interface for configuring fetch instances:
import { FetchBuilder, RetryMode } from '@smooai/fetch';
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
const fetch = new FetchBuilder(UserSchema)
.withTimeout(5000) // 5 second timeout
.withRetry({
attempts: 3,
initialIntervalMs: 1000,
mode: RetryMode.JITTER,
})
.withRateLimit(100, 60000) // 100 requests per minute
.build();
const response = await fetch('https://api.example.com/users/123');
// response.data is typed as { id: string; name: string; email: string }import { FetchBuilder, RetryMode } from '@smooai/fetch';
// Using the default fetch
const response = await fetch('https://api.example.com/data', {
options: {
retry: {
attempts: 3,
initialIntervalMs: 1000,
mode: RetryMode.JITTER,
factor: 2,
jitterAdjustment: 0.5,
onRejection: (error) => {
if (error instanceof HTTPResponseError) {
return error.response.status >= 500;
}
return false;
},
},
},
});
// Or using FetchBuilder
const fetch = new FetchBuilder()
.withRetry({
attempts: 3,
initialIntervalMs: 1000,
mode: RetryMode.JITTER,
factor: 2,
jitterAdjustment: 0.5,
onRejection: (error) => {
if (error instanceof HTTPResponseError) {
return error.response.status >= 500;
}
return false;
},
})
.build();import { FetchBuilder } from '@smooai/fetch';
// Using the default fetch
const response = await fetch('https://api.example.com/slow-endpoint', {
options: {
timeout: {
timeoutMs: 5000,
},
},
});
// Or using FetchBuilder
const fetch = new FetchBuilder()
.withTimeout(5000) // 5 second timeout
.build();
try {
const response = await fetch('https://api.example.com/slow-endpoint');
} catch (error) {
if (error instanceof TimeoutError) {
console.error('Request timed out');
}
}import { FetchBuilder } from '@smooai/fetch';
// Using the default fetch
const response = await fetch('https://api.example.com/data', {
options: {
retry: {
attempts: 1,
initialIntervalMs: 1000,
onRejection: (error) => {
if (error instanceof RatelimitError) {
return error.remainingTimeInRatelimit;
}
return false;
},
},
},
});
// Or using FetchBuilder
const fetch = new FetchBuilder()
.withRateLimit(100, 60000, {
attempts: 1,
initialIntervalMs: 1000,
onRejection: (error) => {
if (error instanceof RatelimitError) {
return error.remainingTimeInRatelimit;
}
return false;
},
})
.build();import { FetchBuilder } from '@smooai/fetch';
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
// Using the default fetch
const response = await fetch('https://api.example.com/users/123', {
options: {
schema: UserSchema,
},
});
// Or using FetchBuilder
const fetch = new FetchBuilder(UserSchema).build();
try {
const response = await fetch('https://api.example.com/users/123');
// response.data is typed as { id: string; name: string; email: string }
} catch (error) {
if (error instanceof HumanReadableSchemaError) {
console.error('Validation failed:', error.message);
// Example output:
// Validation failed: Invalid email format at path: email
}
}import { FetchBuilder } from '@smooai/fetch';
const api = new FetchBuilder()
.withHooks({
// Pre-request hook can modify both URL and request configuration
preRequest: (url, init) => {
const modifiedUrl = new URL(url.toString());
modifiedUrl.searchParams.set('timestamp', Date.now().toString());
init.headers = {
...init.headers,
Authorization: `Bearer ${getToken()}`,
};
return [modifiedUrl, init];
},
postResponseSuccess: (url, init, response) => {
metrics.record({
endpoint: url.pathname,
duration: response.headers.get('x-response-time'),
status: response.status,
});
return response;
},
postResponseError: (url, init, error) => {
if (error.response?.status === 401) {
refreshToken(); // Token expired — refresh and retry
}
return error;
},
})
.build();import { FetchBuilder } from '@smooai/fetch';
// Static headers on every request
const fetch = new FetchBuilder()
.withInit({
headers: {
Authorization: 'Bearer your-auth-token',
'X-API-Key': 'your-api-key',
},
})
.build();
// Or a fresh token per request, fetched asynchronously
const api = new FetchBuilder().withAuthTokenProvider(async () => await tokenStore.getFreshToken(), 'Bearer').build();import fetch, { HTTPResponseError, RatelimitError, RetryError, TimeoutError } from '@smooai/fetch';
try {
const response = await fetch('https://api.example.com/data');
} catch (error) {
if (error instanceof HTTPResponseError) {
console.error('HTTP Error:', error.response.status);
console.error('Response Data:', error.response.data);
} else if (error instanceof RetryError) {
console.error('Retry failed after all attempts');
} else if (error instanceof TimeoutError) {
console.error('Request timed out');
} else if (error instanceof RatelimitError) {
console.error('Rate limit exceeded');
}
}- TypeScript · native Fetch API
- Mollitia — circuit breaker and rate limiter (TypeScript port)
- Polly — resilience engine (.NET port)
- Standard Schema
- @smooai/logger — structured logging (bring your own logger supported)
- @smooai/utils — Standard Schema validation and human-readable error generation
@smooai/fetch is built and open-sourced by Smoo AI — the AI-powered business platform with AI built into every product: CRM, customer support, campaigns, field service, observability, and developer tools.
- 🧰 More open source from Smoo AI — smoo.ai/open-source
- 🧩 Sibling packages — @smooai/file, @smooai/logger, @smooai/config, smooth-operator, smooth (the
thCLI)
Contributions are welcome. This project uses changesets to manage versions and releases.
- Fork the repository and create your branch
- Make your changes (the five ports live in
src/,python/,rust/,go/,dotnet/) - Add a changeset to document them:
pnpm changeset - Open a pull request — reference any related issues
MIT © Smoo AI. See LICENSE.
Built by Smoo AI — AI built into every product.
