Repository files navigation

@smooai/fetch — Resilient, type-safe HTTP for real-world APIs

npmPyPIcrates.ioNuGet

Smoo AIlicenseCI

TypeScriptPythonRustGo.NET

retriesRetry-After awarecircuit breakingW3C traceparent

What it is · Feature tour · Install · Quickstart · Language status · Examples · Platform


Stop writing the same retry logic over and over.@smooai/fetch is a drop-in fetch that 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.

What is this?

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-After headers 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 propagationtraceparent headers 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)

Feature tour

Each capability in a few lines of real, current API — snippets are verified against src/ and the language ports, not pseudocode.

CapabilityWhat you get
🔄Smart retriesBackoff + jitter, only on errors worth retrying
🚦Rate-limit respectRetry-After honored to the second, in all five ports
🔌Circuit breakingFail fast when a dependency is down
🎯Typed responsesSchema-validated data, typed end to end
🔗Hooks + authOne place for tokens, logging, and response policy
📡Trace propagationtraceparent on every request, optional OpenTelemetry

🔄 Smart retries

importfetchfrom'@smooai/fetch';// This won't crash if the API is temporarily downconstresponse=awaitfetch('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.

🚦 Rate-limit respect

constresponse=awaitfetch('https://api.github.com/user/repos');// If GitHub says "slow down":// - Sees 429 + Retry-After: 60// - Automatically waits 60 seconds// - Retries and succeeds

All 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.

🔌 Circuit breaking

import{FetchBuilder}from'@smooai/fetch';constcriticalAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50,// open when ≥50% of calls fail…slidingWindowSize: 10,// …across the last 10 callsopenStateDelayMs: 30000,// stay open 30s, then trial a half-open call}).build();try{awaitcriticalAPI('https://payment-processor.com/charge');}catch(error){// Circuit is open — service is down. Show fallback UI immediately.}

🎯 Typed responses + validation

import{z}from'zod';constUserSchema=z.object({id: z.string(),email: z.string().email(),});constresponse=awaitfetch('https://api.example.com/user',{options: {schema: UserSchema},});// response.data is fully typed as { id: string; email: string }// No more runtime surprises in production

In 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.

🔗 Lifecycle hooks + auth

constapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').withHooks({postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

Every port has both seams: an async auth-token provider (fresh token per request, no client rebuild) and pre-request / post-response hooks.

📡 Trace-context propagation

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:awaitapi('https://api.example.com/users/123');// → headers: { traceparent: '00-<trace-id>-<span-id>-01' }// Without it: same request, no traceparent, zero errors.

The request pipeline

%%{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
Loading

📦 Install

LanguagePackageInstall
TypeScript@smooai/fetchpnpm add @smooai/fetch
Pythonsmooai-fetchpip install smooai-fetch
Rustsmooai-fetchcargo add smooai-fetch
Gogithub.com/SmooAI/fetch/go/fetch/v3go get github.com/SmooAI/fetch/go/fetch/v3
.NETSmooAI.Fetchdotnet add package SmooAI.Fetch

Go note: the module path carries the /v3 major suffix Go requires above v1, so the go/fetch/v3.x tags resolve. The import path is github.com/SmooAI/fetch/go/fetch/v3; the package identifier is still fetch. Tags minted before this change (through go/fetch/v3.4.0) point at commits whose go.mod lacked the suffix and will not resolve — use v3.4.1 or later.

Language-specific source lives in src/ (TypeScript), python/, rust/, go/, and dotnet/.

🚀 Quickstart, in your language

It's just fetch, but resilient — retries, timeout, and Retry-After handling are on by default in every port.

TypeScript (full docs)

importfetchfrom'@smooai/fetch';constresponse=awaitfetch('https://api.example.com/users/123');constuser=awaitresponse.json();

Python (python/)

fromsmooai_fetchimportFetchBuilderbuilder=FetchBuilder().with_timeout(5000).with_retry()
response=awaitbuilder.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/)

varfetch=SmooFetch.Create(options =>{options.BaseUrl="https://api.example.com";options.RetryPolicy=RetryPolicy.ExponentialBackoff(maxRetries:3);});varuser=awaitfetch.GetAsync<User>("/users/me");

Node.js and browser (TypeScript)

// Node.jsimportfetchfrom'@smooai/fetch';// Browser — same API, different entry pointimportfetchfrom'@smooai/fetch/browser';constresponse=awaitfetch('/api/checkout',{method: 'POST',body: {items: cart},});

Five languages, honestly

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:

LanguageResponse typing / validationResilience engineHTTP stack
TypeScriptAny Standard Schema validator (Zod, …)mollitianative fetch
PythonPydantic models via with_schema(...)implemented in-packagehttpx
Rustserde — fetch::<T> deserializes into your typeimplemented in-cratereqwest
GoGenerics — fetch.Get[User](...) decodes into your struct, plus an optional RequestOptions.Validate hook returning SchemaValidationErrorimplemented in-packagenet/http
.NETSystem.Text.Json — GetAsync<T> / PostAsync<TReq, TRes> (no pluggable validator)Polly + System.Threading.RateLimitingHttpClient / 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.

Credential redaction is scoped to what each port actually logs

LanguageWhat it logs about a requestRedaction
TypeScriptmethod, host, path, query string, headers, request body, and the URL in the messagefull — headers, query, URL and body
Rustmethod and URL, on one tracing::debug! eventURL only (userinfo password + query params)
Pythonnothingn/a — no logging sink
Gonothingn/a — no logging sink
.NETnothing (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.


📖 Smart defaults

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.

Graceful degradation

constprimaryAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50}).build();constfallbackAPI=newFetchBuilder().withTimeout(2000).build();asyncfunctiongetWeather(city: string){try{returnawaitprimaryAPI(`https://api1.weather.com/${city}`);}catch(error){console.warn('Primary weather API failed, using fallback');returnawaitfallbackAPI(`https://api2.weather.com/${city}`);}}

🔗 Pairs with @smooai/logger

@smooai/fetch works with @smooai/logger for complete observability across distributed systems.

Automatic correlation ID propagation

importfetch,{FetchBuilder}from'@smooai/fetch';import{AwsServerLogger}from'@smooai/logger/AwsServerLogger';constlogger=newAwsServerLogger({name: 'APIClient'});constapi=newFetchBuilder().withLogger(logger)// That's it.build();// In Service Alogger.info('Starting user flow');// Correlation ID: abc-123constuser=awaitapi('/users/123');// Correlation ID sent as header// In Service B, the correlation ID is automatically extracted and logs are linked.

Credentials are redacted before they reach a log record

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.

Debug production issues faster

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{constresponse=awaitapi('/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"] }// }

📚 Examples

Basic usage

importfetchfrom'@smooai/fetch';// Simple GET requestconstresponse=awaitfetch('https://api.example.com/data');// POST request with JSON body and optionsconstresponse=awaitfetch('https://api.example.com/data',{method: 'POST',headers: {'Content-Type': 'application/json',},body: {key: 'value',},options: {timeout: {timeoutMs: 5000,},retry: {attempts: 3,},},});

(back to examples)

FetchBuilder pattern

The FetchBuilder provides a fluent interface for configuring fetch instances:

import{FetchBuilder,RetryMode}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});constfetch=newFetchBuilder(UserSchema).withTimeout(5000)// 5 second timeout.withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,}).withRateLimit(100,60000)// 100 requests per minute.build();constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }

(back to examples)

Retry

import{FetchBuilder,RetryMode}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},}).build();

(back to examples)

Timeout

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/slow-endpoint',{options: {timeout: {timeoutMs: 5000,},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withTimeout(5000)// 5 second timeout.build();try{constresponse=awaitfetch('https://api.example.com/slow-endpoint');}catch(error){if(errorinstanceofTimeoutError){console.error('Request timed out');}}

(back to examples)

Rate limit

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRateLimit(100,60000,{attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},}).build();

(back to examples)

Schema validation

import{FetchBuilder}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});// Using the default fetchconstresponse=awaitfetch('https://api.example.com/users/123',{options: {schema: UserSchema,},});// Or using FetchBuilderconstfetch=newFetchBuilder(UserSchema).build();try{constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }}catch(error){if(errorinstanceofHumanReadableSchemaError){console.error('Validation failed:',error.message);// Example output:// Validation failed: Invalid email format at path: email}}

(back to examples)

Lifecycle hooks

import{FetchBuilder}from'@smooai/fetch';constapi=newFetchBuilder().withHooks({// Pre-request hook can modify both URL and request configurationpreRequest: (url,init)=>{constmodifiedUrl=newURL(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,});returnresponse;},postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

(back to examples)

Predefined authentication

import{FetchBuilder}from'@smooai/fetch';// Static headers on every requestconstfetch=newFetchBuilder().withInit({headers: {Authorization: 'Bearer your-auth-token','X-API-Key': 'your-api-key',},}).build();// Or a fresh token per request, fetched asynchronouslyconstapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').build();

(back to examples)

Error handling

importfetch,{HTTPResponseError,RatelimitError,RetryError,TimeoutError}from'@smooai/fetch';try{constresponse=awaitfetch('https://api.example.com/data');}catch(error){if(errorinstanceofHTTPResponseError){console.error('HTTP Error:',error.response.status);console.error('Response Data:',error.response.data);}elseif(errorinstanceofRetryError){console.error('Retry failed after all attempts');}elseif(errorinstanceofTimeoutError){console.error('Request timed out');}elseif(errorinstanceofRatelimitError){console.error('Rate limit exceeded');}}

(back to examples)

Built with

  • 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

🧩 Part of Smoo AI

@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.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository and create your branch
  2. Make your changes (the five ports live in src/, python/, rust/, go/, dotnet/)
  3. Add a changeset to document them: pnpm changeset
  4. Open a pull request — reference any related issues

📄 License

MIT © Smoo AI. See LICENSE.


Built by Smoo AI — AI built into every product.

About

Multi-language HTTP client (TypeScript, Python, Rust, Go) with smart retries, circuit breaking, rate limiting, request deduplication, and Standard Schema validation. Built on native fetch for Node.js and browser.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

@smooai/fetch — Resilient, type-safe HTTP for real-world APIs

npmPyPIcrates.ioNuGet

Smoo AIlicenseCI

TypeScriptPythonRustGo.NET

retriesRetry-After awarecircuit breakingW3C traceparent

What it is · Feature tour · Install · Quickstart · Language status · Examples · Platform


Stop writing the same retry logic over and over.@smooai/fetch is a drop-in fetch that 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.

What is this?

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-After headers 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 propagationtraceparent headers 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)

Feature tour

Each capability in a few lines of real, current API — snippets are verified against src/ and the language ports, not pseudocode.

CapabilityWhat you get
🔄Smart retriesBackoff + jitter, only on errors worth retrying
🚦Rate-limit respectRetry-After honored to the second, in all five ports
🔌Circuit breakingFail fast when a dependency is down
🎯Typed responsesSchema-validated data, typed end to end
🔗Hooks + authOne place for tokens, logging, and response policy
📡Trace propagationtraceparent on every request, optional OpenTelemetry

🔄 Smart retries

importfetchfrom'@smooai/fetch';// This won't crash if the API is temporarily downconstresponse=awaitfetch('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.

🚦 Rate-limit respect

constresponse=awaitfetch('https://api.github.com/user/repos');// If GitHub says "slow down":// - Sees 429 + Retry-After: 60// - Automatically waits 60 seconds// - Retries and succeeds

All 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.

🔌 Circuit breaking

import{FetchBuilder}from'@smooai/fetch';constcriticalAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50,// open when ≥50% of calls fail…slidingWindowSize: 10,// …across the last 10 callsopenStateDelayMs: 30000,// stay open 30s, then trial a half-open call}).build();try{awaitcriticalAPI('https://payment-processor.com/charge');}catch(error){// Circuit is open — service is down. Show fallback UI immediately.}

🎯 Typed responses + validation

import{z}from'zod';constUserSchema=z.object({id: z.string(),email: z.string().email(),});constresponse=awaitfetch('https://api.example.com/user',{options: {schema: UserSchema},});// response.data is fully typed as { id: string; email: string }// No more runtime surprises in production

In 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.

🔗 Lifecycle hooks + auth

constapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').withHooks({postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

Every port has both seams: an async auth-token provider (fresh token per request, no client rebuild) and pre-request / post-response hooks.

📡 Trace-context propagation

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:awaitapi('https://api.example.com/users/123');// → headers: { traceparent: '00-<trace-id>-<span-id>-01' }// Without it: same request, no traceparent, zero errors.

The request pipeline

%%{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
Loading

📦 Install

LanguagePackageInstall
TypeScript@smooai/fetchpnpm add @smooai/fetch
Pythonsmooai-fetchpip install smooai-fetch
Rustsmooai-fetchcargo add smooai-fetch
Gogithub.com/SmooAI/fetch/go/fetch/v3go get github.com/SmooAI/fetch/go/fetch/v3
.NETSmooAI.Fetchdotnet add package SmooAI.Fetch

Go note: the module path carries the /v3 major suffix Go requires above v1, so the go/fetch/v3.x tags resolve. The import path is github.com/SmooAI/fetch/go/fetch/v3; the package identifier is still fetch. Tags minted before this change (through go/fetch/v3.4.0) point at commits whose go.mod lacked the suffix and will not resolve — use v3.4.1 or later.

Language-specific source lives in src/ (TypeScript), python/, rust/, go/, and dotnet/.

🚀 Quickstart, in your language

It's just fetch, but resilient — retries, timeout, and Retry-After handling are on by default in every port.

TypeScript (full docs)

importfetchfrom'@smooai/fetch';constresponse=awaitfetch('https://api.example.com/users/123');constuser=awaitresponse.json();

Python (python/)

fromsmooai_fetchimportFetchBuilderbuilder=FetchBuilder().with_timeout(5000).with_retry()
response=awaitbuilder.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/)

varfetch=SmooFetch.Create(options =>{options.BaseUrl="https://api.example.com";options.RetryPolicy=RetryPolicy.ExponentialBackoff(maxRetries:3);});varuser=awaitfetch.GetAsync<User>("/users/me");

Node.js and browser (TypeScript)

// Node.jsimportfetchfrom'@smooai/fetch';// Browser — same API, different entry pointimportfetchfrom'@smooai/fetch/browser';constresponse=awaitfetch('/api/checkout',{method: 'POST',body: {items: cart},});

Five languages, honestly

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:

LanguageResponse typing / validationResilience engineHTTP stack
TypeScriptAny Standard Schema validator (Zod, …)mollitianative fetch
PythonPydantic models via with_schema(...)implemented in-packagehttpx
Rustserde — fetch::<T> deserializes into your typeimplemented in-cratereqwest
GoGenerics — fetch.Get[User](...) decodes into your struct, plus an optional RequestOptions.Validate hook returning SchemaValidationErrorimplemented in-packagenet/http
.NETSystem.Text.Json — GetAsync<T> / PostAsync<TReq, TRes> (no pluggable validator)Polly + System.Threading.RateLimitingHttpClient / 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.

Credential redaction is scoped to what each port actually logs

LanguageWhat it logs about a requestRedaction
TypeScriptmethod, host, path, query string, headers, request body, and the URL in the messagefull — headers, query, URL and body
Rustmethod and URL, on one tracing::debug! eventURL only (userinfo password + query params)
Pythonnothingn/a — no logging sink
Gonothingn/a — no logging sink
.NETnothing (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.


📖 Smart defaults

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.

Graceful degradation

constprimaryAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50}).build();constfallbackAPI=newFetchBuilder().withTimeout(2000).build();asyncfunctiongetWeather(city: string){try{returnawaitprimaryAPI(`https://api1.weather.com/${city}`);}catch(error){console.warn('Primary weather API failed, using fallback');returnawaitfallbackAPI(`https://api2.weather.com/${city}`);}}

🔗 Pairs with @smooai/logger

@smooai/fetch works with @smooai/logger for complete observability across distributed systems.

Automatic correlation ID propagation

importfetch,{FetchBuilder}from'@smooai/fetch';import{AwsServerLogger}from'@smooai/logger/AwsServerLogger';constlogger=newAwsServerLogger({name: 'APIClient'});constapi=newFetchBuilder().withLogger(logger)// That's it.build();// In Service Alogger.info('Starting user flow');// Correlation ID: abc-123constuser=awaitapi('/users/123');// Correlation ID sent as header// In Service B, the correlation ID is automatically extracted and logs are linked.

Credentials are redacted before they reach a log record

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.

Debug production issues faster

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{constresponse=awaitapi('/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"] }// }

📚 Examples

Basic usage

importfetchfrom'@smooai/fetch';// Simple GET requestconstresponse=awaitfetch('https://api.example.com/data');// POST request with JSON body and optionsconstresponse=awaitfetch('https://api.example.com/data',{method: 'POST',headers: {'Content-Type': 'application/json',},body: {key: 'value',},options: {timeout: {timeoutMs: 5000,},retry: {attempts: 3,},},});

(back to examples)

FetchBuilder pattern

The FetchBuilder provides a fluent interface for configuring fetch instances:

import{FetchBuilder,RetryMode}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});constfetch=newFetchBuilder(UserSchema).withTimeout(5000)// 5 second timeout.withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,}).withRateLimit(100,60000)// 100 requests per minute.build();constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }

(back to examples)

Retry

import{FetchBuilder,RetryMode}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},}).build();

(back to examples)

Timeout

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/slow-endpoint',{options: {timeout: {timeoutMs: 5000,},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withTimeout(5000)// 5 second timeout.build();try{constresponse=awaitfetch('https://api.example.com/slow-endpoint');}catch(error){if(errorinstanceofTimeoutError){console.error('Request timed out');}}

(back to examples)

Rate limit

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRateLimit(100,60000,{attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},}).build();

(back to examples)

Schema validation

import{FetchBuilder}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});// Using the default fetchconstresponse=awaitfetch('https://api.example.com/users/123',{options: {schema: UserSchema,},});// Or using FetchBuilderconstfetch=newFetchBuilder(UserSchema).build();try{constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }}catch(error){if(errorinstanceofHumanReadableSchemaError){console.error('Validation failed:',error.message);// Example output:// Validation failed: Invalid email format at path: email}}

(back to examples)

Lifecycle hooks

import{FetchBuilder}from'@smooai/fetch';constapi=newFetchBuilder().withHooks({// Pre-request hook can modify both URL and request configurationpreRequest: (url,init)=>{constmodifiedUrl=newURL(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,});returnresponse;},postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

(back to examples)

Predefined authentication

import{FetchBuilder}from'@smooai/fetch';// Static headers on every requestconstfetch=newFetchBuilder().withInit({headers: {Authorization: 'Bearer your-auth-token','X-API-Key': 'your-api-key',},}).build();// Or a fresh token per request, fetched asynchronouslyconstapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').build();

(back to examples)

Error handling

importfetch,{HTTPResponseError,RatelimitError,RetryError,TimeoutError}from'@smooai/fetch';try{constresponse=awaitfetch('https://api.example.com/data');}catch(error){if(errorinstanceofHTTPResponseError){console.error('HTTP Error:',error.response.status);console.error('Response Data:',error.response.data);}elseif(errorinstanceofRetryError){console.error('Retry failed after all attempts');}elseif(errorinstanceofTimeoutError){console.error('Request timed out');}elseif(errorinstanceofRatelimitError){console.error('Rate limit exceeded');}}

(back to examples)

Built with

  • 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

🧩 Part of Smoo AI

@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.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository and create your branch
  2. Make your changes (the five ports live in src/, python/, rust/, go/, dotnet/)
  3. Add a changeset to document them: pnpm changeset
  4. Open a pull request — reference any related issues

📄 License

MIT © Smoo AI. See LICENSE.


Built by Smoo AI — AI built into every product.

About

Multi-language HTTP client (TypeScript, Python, Rust, Go) with smart retries, circuit breaking, rate limiting, request deduplication, and Standard Schema validation. Built on native fetch for Node.js and browser.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@smooai/fetch — Resilient, type-safe HTTP for real-world APIs

npmPyPIcrates.ioNuGet

Smoo AIlicenseCI

TypeScriptPythonRustGo.NET

retriesRetry-After awarecircuit breakingW3C traceparent

What it is · Feature tour · Install · Quickstart · Language status · Examples · Platform


Stop writing the same retry logic over and over.@smooai/fetch is a drop-in fetch that 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.

What is this?

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-After headers 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 propagationtraceparent headers 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)

Feature tour

Each capability in a few lines of real, current API — snippets are verified against src/ and the language ports, not pseudocode.

CapabilityWhat you get
🔄Smart retriesBackoff + jitter, only on errors worth retrying
🚦Rate-limit respectRetry-After honored to the second, in all five ports
🔌Circuit breakingFail fast when a dependency is down
🎯Typed responsesSchema-validated data, typed end to end
🔗Hooks + authOne place for tokens, logging, and response policy
📡Trace propagationtraceparent on every request, optional OpenTelemetry

🔄 Smart retries

importfetchfrom'@smooai/fetch';// This won't crash if the API is temporarily downconstresponse=awaitfetch('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.

🚦 Rate-limit respect

constresponse=awaitfetch('https://api.github.com/user/repos');// If GitHub says "slow down":// - Sees 429 + Retry-After: 60// - Automatically waits 60 seconds// - Retries and succeeds

All 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.

🔌 Circuit breaking

import{FetchBuilder}from'@smooai/fetch';constcriticalAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50,// open when ≥50% of calls fail…slidingWindowSize: 10,// …across the last 10 callsopenStateDelayMs: 30000,// stay open 30s, then trial a half-open call}).build();try{awaitcriticalAPI('https://payment-processor.com/charge');}catch(error){// Circuit is open — service is down. Show fallback UI immediately.}

🎯 Typed responses + validation

import{z}from'zod';constUserSchema=z.object({id: z.string(),email: z.string().email(),});constresponse=awaitfetch('https://api.example.com/user',{options: {schema: UserSchema},});// response.data is fully typed as { id: string; email: string }// No more runtime surprises in production

In 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.

🔗 Lifecycle hooks + auth

constapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').withHooks({postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

Every port has both seams: an async auth-token provider (fresh token per request, no client rebuild) and pre-request / post-response hooks.

📡 Trace-context propagation

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:awaitapi('https://api.example.com/users/123');// → headers: { traceparent: '00-<trace-id>-<span-id>-01' }// Without it: same request, no traceparent, zero errors.

The request pipeline

%%{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
Loading

📦 Install

LanguagePackageInstall
TypeScript@smooai/fetchpnpm add @smooai/fetch
Pythonsmooai-fetchpip install smooai-fetch
Rustsmooai-fetchcargo add smooai-fetch
Gogithub.com/SmooAI/fetch/go/fetch/v3go get github.com/SmooAI/fetch/go/fetch/v3
.NETSmooAI.Fetchdotnet add package SmooAI.Fetch

Go note: the module path carries the /v3 major suffix Go requires above v1, so the go/fetch/v3.x tags resolve. The import path is github.com/SmooAI/fetch/go/fetch/v3; the package identifier is still fetch. Tags minted before this change (through go/fetch/v3.4.0) point at commits whose go.mod lacked the suffix and will not resolve — use v3.4.1 or later.

Language-specific source lives in src/ (TypeScript), python/, rust/, go/, and dotnet/.

🚀 Quickstart, in your language

It's just fetch, but resilient — retries, timeout, and Retry-After handling are on by default in every port.

TypeScript (full docs)

importfetchfrom'@smooai/fetch';constresponse=awaitfetch('https://api.example.com/users/123');constuser=awaitresponse.json();

Python (python/)

fromsmooai_fetchimportFetchBuilderbuilder=FetchBuilder().with_timeout(5000).with_retry()
response=awaitbuilder.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/)

varfetch=SmooFetch.Create(options =>{options.BaseUrl="https://api.example.com";options.RetryPolicy=RetryPolicy.ExponentialBackoff(maxRetries:3);});varuser=awaitfetch.GetAsync<User>("/users/me");

Node.js and browser (TypeScript)

// Node.jsimportfetchfrom'@smooai/fetch';// Browser — same API, different entry pointimportfetchfrom'@smooai/fetch/browser';constresponse=awaitfetch('/api/checkout',{method: 'POST',body: {items: cart},});

Five languages, honestly

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:

LanguageResponse typing / validationResilience engineHTTP stack
TypeScriptAny Standard Schema validator (Zod, …)mollitianative fetch
PythonPydantic models via with_schema(...)implemented in-packagehttpx
Rustserde — fetch::<T> deserializes into your typeimplemented in-cratereqwest
GoGenerics — fetch.Get[User](...) decodes into your struct, plus an optional RequestOptions.Validate hook returning SchemaValidationErrorimplemented in-packagenet/http
.NETSystem.Text.Json — GetAsync<T> / PostAsync<TReq, TRes> (no pluggable validator)Polly + System.Threading.RateLimitingHttpClient / 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.

Credential redaction is scoped to what each port actually logs

LanguageWhat it logs about a requestRedaction
TypeScriptmethod, host, path, query string, headers, request body, and the URL in the messagefull — headers, query, URL and body
Rustmethod and URL, on one tracing::debug! eventURL only (userinfo password + query params)
Pythonnothingn/a — no logging sink
Gonothingn/a — no logging sink
.NETnothing (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.


📖 Smart defaults

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.

Graceful degradation

constprimaryAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50}).build();constfallbackAPI=newFetchBuilder().withTimeout(2000).build();asyncfunctiongetWeather(city: string){try{returnawaitprimaryAPI(`https://api1.weather.com/${city}`);}catch(error){console.warn('Primary weather API failed, using fallback');returnawaitfallbackAPI(`https://api2.weather.com/${city}`);}}

🔗 Pairs with @smooai/logger

@smooai/fetch works with @smooai/logger for complete observability across distributed systems.

Automatic correlation ID propagation

importfetch,{FetchBuilder}from'@smooai/fetch';import{AwsServerLogger}from'@smooai/logger/AwsServerLogger';constlogger=newAwsServerLogger({name: 'APIClient'});constapi=newFetchBuilder().withLogger(logger)// That's it.build();// In Service Alogger.info('Starting user flow');// Correlation ID: abc-123constuser=awaitapi('/users/123');// Correlation ID sent as header// In Service B, the correlation ID is automatically extracted and logs are linked.

Credentials are redacted before they reach a log record

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.

Debug production issues faster

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{constresponse=awaitapi('/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"] }// }

📚 Examples

Basic usage

importfetchfrom'@smooai/fetch';// Simple GET requestconstresponse=awaitfetch('https://api.example.com/data');// POST request with JSON body and optionsconstresponse=awaitfetch('https://api.example.com/data',{method: 'POST',headers: {'Content-Type': 'application/json',},body: {key: 'value',},options: {timeout: {timeoutMs: 5000,},retry: {attempts: 3,},},});

(back to examples)

FetchBuilder pattern

The FetchBuilder provides a fluent interface for configuring fetch instances:

import{FetchBuilder,RetryMode}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});constfetch=newFetchBuilder(UserSchema).withTimeout(5000)// 5 second timeout.withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,}).withRateLimit(100,60000)// 100 requests per minute.build();constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }

(back to examples)

Retry

import{FetchBuilder,RetryMode}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},}).build();

(back to examples)

Timeout

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/slow-endpoint',{options: {timeout: {timeoutMs: 5000,},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withTimeout(5000)// 5 second timeout.build();try{constresponse=awaitfetch('https://api.example.com/slow-endpoint');}catch(error){if(errorinstanceofTimeoutError){console.error('Request timed out');}}

(back to examples)

Rate limit

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRateLimit(100,60000,{attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},}).build();

(back to examples)

Schema validation

import{FetchBuilder}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});// Using the default fetchconstresponse=awaitfetch('https://api.example.com/users/123',{options: {schema: UserSchema,},});// Or using FetchBuilderconstfetch=newFetchBuilder(UserSchema).build();try{constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }}catch(error){if(errorinstanceofHumanReadableSchemaError){console.error('Validation failed:',error.message);// Example output:// Validation failed: Invalid email format at path: email}}

(back to examples)

Lifecycle hooks

import{FetchBuilder}from'@smooai/fetch';constapi=newFetchBuilder().withHooks({// Pre-request hook can modify both URL and request configurationpreRequest: (url,init)=>{constmodifiedUrl=newURL(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,});returnresponse;},postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

(back to examples)

Predefined authentication

import{FetchBuilder}from'@smooai/fetch';// Static headers on every requestconstfetch=newFetchBuilder().withInit({headers: {Authorization: 'Bearer your-auth-token','X-API-Key': 'your-api-key',},}).build();// Or a fresh token per request, fetched asynchronouslyconstapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').build();

(back to examples)

Error handling

importfetch,{HTTPResponseError,RatelimitError,RetryError,TimeoutError}from'@smooai/fetch';try{constresponse=awaitfetch('https://api.example.com/data');}catch(error){if(errorinstanceofHTTPResponseError){console.error('HTTP Error:',error.response.status);console.error('Response Data:',error.response.data);}elseif(errorinstanceofRetryError){console.error('Retry failed after all attempts');}elseif(errorinstanceofTimeoutError){console.error('Request timed out');}elseif(errorinstanceofRatelimitError){console.error('Rate limit exceeded');}}

(back to examples)

Built with

  • 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

🧩 Part of Smoo AI

@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.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository and create your branch
  2. Make your changes (the five ports live in src/, python/, rust/, go/, dotnet/)
  3. Add a changeset to document them: pnpm changeset
  4. Open a pull request — reference any related issues

📄 License

MIT © Smoo AI. See LICENSE.


Built by Smoo AI — AI built into every product.

About

Multi-language HTTP client (TypeScript, Python, Rust, Go) with smart retries, circuit breaking, rate limiting, request deduplication, and Standard Schema validation. Built on native fetch for Node.js and browser.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@smooai/fetch — Resilient, type-safe HTTP for real-world APIs

npmPyPIcrates.ioNuGet

Smoo AIlicenseCI

TypeScriptPythonRustGo.NET

retriesRetry-After awarecircuit breakingW3C traceparent

What it is · Feature tour · Install · Quickstart · Language status · Examples · Platform


Stop writing the same retry logic over and over.@smooai/fetch is a drop-in fetch that 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.

What is this?

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-After headers 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 propagationtraceparent headers 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)

Feature tour

Each capability in a few lines of real, current API — snippets are verified against src/ and the language ports, not pseudocode.

CapabilityWhat you get
🔄Smart retriesBackoff + jitter, only on errors worth retrying
🚦Rate-limit respectRetry-After honored to the second, in all five ports
🔌Circuit breakingFail fast when a dependency is down
🎯Typed responsesSchema-validated data, typed end to end
🔗Hooks + authOne place for tokens, logging, and response policy
📡Trace propagationtraceparent on every request, optional OpenTelemetry

🔄 Smart retries

importfetchfrom'@smooai/fetch';// This won't crash if the API is temporarily downconstresponse=awaitfetch('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.

🚦 Rate-limit respect

constresponse=awaitfetch('https://api.github.com/user/repos');// If GitHub says "slow down":// - Sees 429 + Retry-After: 60// - Automatically waits 60 seconds// - Retries and succeeds

All 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.

🔌 Circuit breaking

import{FetchBuilder}from'@smooai/fetch';constcriticalAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50,// open when ≥50% of calls fail…slidingWindowSize: 10,// …across the last 10 callsopenStateDelayMs: 30000,// stay open 30s, then trial a half-open call}).build();try{awaitcriticalAPI('https://payment-processor.com/charge');}catch(error){// Circuit is open — service is down. Show fallback UI immediately.}

🎯 Typed responses + validation

import{z}from'zod';constUserSchema=z.object({id: z.string(),email: z.string().email(),});constresponse=awaitfetch('https://api.example.com/user',{options: {schema: UserSchema},});// response.data is fully typed as { id: string; email: string }// No more runtime surprises in production

In 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.

🔗 Lifecycle hooks + auth

constapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').withHooks({postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

Every port has both seams: an async auth-token provider (fresh token per request, no client rebuild) and pre-request / post-response hooks.

📡 Trace-context propagation

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:awaitapi('https://api.example.com/users/123');// → headers: { traceparent: '00-<trace-id>-<span-id>-01' }// Without it: same request, no traceparent, zero errors.

The request pipeline

%%{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
Loading

📦 Install

LanguagePackageInstall
TypeScript@smooai/fetchpnpm add @smooai/fetch
Pythonsmooai-fetchpip install smooai-fetch
Rustsmooai-fetchcargo add smooai-fetch
Gogithub.com/SmooAI/fetch/go/fetch/v3go get github.com/SmooAI/fetch/go/fetch/v3
.NETSmooAI.Fetchdotnet add package SmooAI.Fetch

Go note: the module path carries the /v3 major suffix Go requires above v1, so the go/fetch/v3.x tags resolve. The import path is github.com/SmooAI/fetch/go/fetch/v3; the package identifier is still fetch. Tags minted before this change (through go/fetch/v3.4.0) point at commits whose go.mod lacked the suffix and will not resolve — use v3.4.1 or later.

Language-specific source lives in src/ (TypeScript), python/, rust/, go/, and dotnet/.

🚀 Quickstart, in your language

It's just fetch, but resilient — retries, timeout, and Retry-After handling are on by default in every port.

TypeScript (full docs)

importfetchfrom'@smooai/fetch';constresponse=awaitfetch('https://api.example.com/users/123');constuser=awaitresponse.json();

Python (python/)

fromsmooai_fetchimportFetchBuilderbuilder=FetchBuilder().with_timeout(5000).with_retry()
response=awaitbuilder.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/)

varfetch=SmooFetch.Create(options =>{options.BaseUrl="https://api.example.com";options.RetryPolicy=RetryPolicy.ExponentialBackoff(maxRetries:3);});varuser=awaitfetch.GetAsync<User>("/users/me");

Node.js and browser (TypeScript)

// Node.jsimportfetchfrom'@smooai/fetch';// Browser — same API, different entry pointimportfetchfrom'@smooai/fetch/browser';constresponse=awaitfetch('/api/checkout',{method: 'POST',body: {items: cart},});

Five languages, honestly

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:

LanguageResponse typing / validationResilience engineHTTP stack
TypeScriptAny Standard Schema validator (Zod, …)mollitianative fetch
PythonPydantic models via with_schema(...)implemented in-packagehttpx
Rustserde — fetch::<T> deserializes into your typeimplemented in-cratereqwest
GoGenerics — fetch.Get[User](...) decodes into your struct, plus an optional RequestOptions.Validate hook returning SchemaValidationErrorimplemented in-packagenet/http
.NETSystem.Text.Json — GetAsync<T> / PostAsync<TReq, TRes> (no pluggable validator)Polly + System.Threading.RateLimitingHttpClient / 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.

Credential redaction is scoped to what each port actually logs

LanguageWhat it logs about a requestRedaction
TypeScriptmethod, host, path, query string, headers, request body, and the URL in the messagefull — headers, query, URL and body
Rustmethod and URL, on one tracing::debug! eventURL only (userinfo password + query params)
Pythonnothingn/a — no logging sink
Gonothingn/a — no logging sink
.NETnothing (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.


📖 Smart defaults

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.

Graceful degradation

constprimaryAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50}).build();constfallbackAPI=newFetchBuilder().withTimeout(2000).build();asyncfunctiongetWeather(city: string){try{returnawaitprimaryAPI(`https://api1.weather.com/${city}`);}catch(error){console.warn('Primary weather API failed, using fallback');returnawaitfallbackAPI(`https://api2.weather.com/${city}`);}}

🔗 Pairs with @smooai/logger

@smooai/fetch works with @smooai/logger for complete observability across distributed systems.

Automatic correlation ID propagation

importfetch,{FetchBuilder}from'@smooai/fetch';import{AwsServerLogger}from'@smooai/logger/AwsServerLogger';constlogger=newAwsServerLogger({name: 'APIClient'});constapi=newFetchBuilder().withLogger(logger)// That's it.build();// In Service Alogger.info('Starting user flow');// Correlation ID: abc-123constuser=awaitapi('/users/123');// Correlation ID sent as header// In Service B, the correlation ID is automatically extracted and logs are linked.

Credentials are redacted before they reach a log record

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.

Debug production issues faster

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{constresponse=awaitapi('/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"] }// }

📚 Examples

Basic usage

importfetchfrom'@smooai/fetch';// Simple GET requestconstresponse=awaitfetch('https://api.example.com/data');// POST request with JSON body and optionsconstresponse=awaitfetch('https://api.example.com/data',{method: 'POST',headers: {'Content-Type': 'application/json',},body: {key: 'value',},options: {timeout: {timeoutMs: 5000,},retry: {attempts: 3,},},});

(back to examples)

FetchBuilder pattern

The FetchBuilder provides a fluent interface for configuring fetch instances:

import{FetchBuilder,RetryMode}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});constfetch=newFetchBuilder(UserSchema).withTimeout(5000)// 5 second timeout.withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,}).withRateLimit(100,60000)// 100 requests per minute.build();constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }

(back to examples)

Retry

import{FetchBuilder,RetryMode}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},}).build();

(back to examples)

Timeout

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/slow-endpoint',{options: {timeout: {timeoutMs: 5000,},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withTimeout(5000)// 5 second timeout.build();try{constresponse=awaitfetch('https://api.example.com/slow-endpoint');}catch(error){if(errorinstanceofTimeoutError){console.error('Request timed out');}}

(back to examples)

Rate limit

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRateLimit(100,60000,{attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},}).build();

(back to examples)

Schema validation

import{FetchBuilder}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});// Using the default fetchconstresponse=awaitfetch('https://api.example.com/users/123',{options: {schema: UserSchema,},});// Or using FetchBuilderconstfetch=newFetchBuilder(UserSchema).build();try{constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }}catch(error){if(errorinstanceofHumanReadableSchemaError){console.error('Validation failed:',error.message);// Example output:// Validation failed: Invalid email format at path: email}}

(back to examples)

Lifecycle hooks

import{FetchBuilder}from'@smooai/fetch';constapi=newFetchBuilder().withHooks({// Pre-request hook can modify both URL and request configurationpreRequest: (url,init)=>{constmodifiedUrl=newURL(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,});returnresponse;},postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

(back to examples)

Predefined authentication

import{FetchBuilder}from'@smooai/fetch';// Static headers on every requestconstfetch=newFetchBuilder().withInit({headers: {Authorization: 'Bearer your-auth-token','X-API-Key': 'your-api-key',},}).build();// Or a fresh token per request, fetched asynchronouslyconstapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').build();

(back to examples)

Error handling

importfetch,{HTTPResponseError,RatelimitError,RetryError,TimeoutError}from'@smooai/fetch';try{constresponse=awaitfetch('https://api.example.com/data');}catch(error){if(errorinstanceofHTTPResponseError){console.error('HTTP Error:',error.response.status);console.error('Response Data:',error.response.data);}elseif(errorinstanceofRetryError){console.error('Retry failed after all attempts');}elseif(errorinstanceofTimeoutError){console.error('Request timed out');}elseif(errorinstanceofRatelimitError){console.error('Rate limit exceeded');}}

(back to examples)

Built with

  • 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

🧩 Part of Smoo AI

@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.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository and create your branch
  2. Make your changes (the five ports live in src/, python/, rust/, go/, dotnet/)
  3. Add a changeset to document them: pnpm changeset
  4. Open a pull request — reference any related issues

📄 License

MIT © Smoo AI. See LICENSE.


Built by Smoo AI — AI built into every product.

About

Multi-language HTTP client (TypeScript, Python, Rust, Go) with smart retries, circuit breaking, rate limiting, request deduplication, and Standard Schema validation. Built on native fetch for Node.js and browser.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

@smooai/fetch — Resilient, type-safe HTTP for real-world APIs

npmPyPIcrates.ioNuGet

Smoo AIlicenseCI

TypeScriptPythonRustGo.NET

retriesRetry-After awarecircuit breakingW3C traceparent

What it is · Feature tour · Install · Quickstart · Language status · Examples · Platform


Stop writing the same retry logic over and over.@smooai/fetch is a drop-in fetch that 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.

What is this?

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-After headers 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 propagationtraceparent headers 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)

Feature tour

Each capability in a few lines of real, current API — snippets are verified against src/ and the language ports, not pseudocode.

CapabilityWhat you get
🔄Smart retriesBackoff + jitter, only on errors worth retrying
🚦Rate-limit respectRetry-After honored to the second, in all five ports
🔌Circuit breakingFail fast when a dependency is down
🎯Typed responsesSchema-validated data, typed end to end
🔗Hooks + authOne place for tokens, logging, and response policy
📡Trace propagationtraceparent on every request, optional OpenTelemetry

🔄 Smart retries

importfetchfrom'@smooai/fetch';// This won't crash if the API is temporarily downconstresponse=awaitfetch('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.

🚦 Rate-limit respect

constresponse=awaitfetch('https://api.github.com/user/repos');// If GitHub says "slow down":// - Sees 429 + Retry-After: 60// - Automatically waits 60 seconds// - Retries and succeeds

All 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.

🔌 Circuit breaking

import{FetchBuilder}from'@smooai/fetch';constcriticalAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50,// open when ≥50% of calls fail…slidingWindowSize: 10,// …across the last 10 callsopenStateDelayMs: 30000,// stay open 30s, then trial a half-open call}).build();try{awaitcriticalAPI('https://payment-processor.com/charge');}catch(error){// Circuit is open — service is down. Show fallback UI immediately.}

🎯 Typed responses + validation

import{z}from'zod';constUserSchema=z.object({id: z.string(),email: z.string().email(),});constresponse=awaitfetch('https://api.example.com/user',{options: {schema: UserSchema},});// response.data is fully typed as { id: string; email: string }// No more runtime surprises in production

In 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.

🔗 Lifecycle hooks + auth

constapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').withHooks({postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

Every port has both seams: an async auth-token provider (fresh token per request, no client rebuild) and pre-request / post-response hooks.

📡 Trace-context propagation

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:awaitapi('https://api.example.com/users/123');// → headers: { traceparent: '00-<trace-id>-<span-id>-01' }// Without it: same request, no traceparent, zero errors.

The request pipeline

%%{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
Loading

📦 Install

LanguagePackageInstall
TypeScript@smooai/fetchpnpm add @smooai/fetch
Pythonsmooai-fetchpip install smooai-fetch
Rustsmooai-fetchcargo add smooai-fetch
Gogithub.com/SmooAI/fetch/go/fetch/v3go get github.com/SmooAI/fetch/go/fetch/v3
.NETSmooAI.Fetchdotnet add package SmooAI.Fetch

Go note: the module path carries the /v3 major suffix Go requires above v1, so the go/fetch/v3.x tags resolve. The import path is github.com/SmooAI/fetch/go/fetch/v3; the package identifier is still fetch. Tags minted before this change (through go/fetch/v3.4.0) point at commits whose go.mod lacked the suffix and will not resolve — use v3.4.1 or later.

Language-specific source lives in src/ (TypeScript), python/, rust/, go/, and dotnet/.

🚀 Quickstart, in your language

It's just fetch, but resilient — retries, timeout, and Retry-After handling are on by default in every port.

TypeScript (full docs)

importfetchfrom'@smooai/fetch';constresponse=awaitfetch('https://api.example.com/users/123');constuser=awaitresponse.json();

Python (python/)

fromsmooai_fetchimportFetchBuilderbuilder=FetchBuilder().with_timeout(5000).with_retry()
response=awaitbuilder.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/)

varfetch=SmooFetch.Create(options =>{options.BaseUrl="https://api.example.com";options.RetryPolicy=RetryPolicy.ExponentialBackoff(maxRetries:3);});varuser=awaitfetch.GetAsync<User>("/users/me");

Node.js and browser (TypeScript)

// Node.jsimportfetchfrom'@smooai/fetch';// Browser — same API, different entry pointimportfetchfrom'@smooai/fetch/browser';constresponse=awaitfetch('/api/checkout',{method: 'POST',body: {items: cart},});

Five languages, honestly

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:

LanguageResponse typing / validationResilience engineHTTP stack
TypeScriptAny Standard Schema validator (Zod, …)mollitianative fetch
PythonPydantic models via with_schema(...)implemented in-packagehttpx
Rustserde — fetch::<T> deserializes into your typeimplemented in-cratereqwest
GoGenerics — fetch.Get[User](...) decodes into your struct, plus an optional RequestOptions.Validate hook returning SchemaValidationErrorimplemented in-packagenet/http
.NETSystem.Text.Json — GetAsync<T> / PostAsync<TReq, TRes> (no pluggable validator)Polly + System.Threading.RateLimitingHttpClient / 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.

Credential redaction is scoped to what each port actually logs

LanguageWhat it logs about a requestRedaction
TypeScriptmethod, host, path, query string, headers, request body, and the URL in the messagefull — headers, query, URL and body
Rustmethod and URL, on one tracing::debug! eventURL only (userinfo password + query params)
Pythonnothingn/a — no logging sink
Gonothingn/a — no logging sink
.NETnothing (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.


📖 Smart defaults

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.

Graceful degradation

constprimaryAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50}).build();constfallbackAPI=newFetchBuilder().withTimeout(2000).build();asyncfunctiongetWeather(city: string){try{returnawaitprimaryAPI(`https://api1.weather.com/${city}`);}catch(error){console.warn('Primary weather API failed, using fallback');returnawaitfallbackAPI(`https://api2.weather.com/${city}`);}}

🔗 Pairs with @smooai/logger

@smooai/fetch works with @smooai/logger for complete observability across distributed systems.

Automatic correlation ID propagation

importfetch,{FetchBuilder}from'@smooai/fetch';import{AwsServerLogger}from'@smooai/logger/AwsServerLogger';constlogger=newAwsServerLogger({name: 'APIClient'});constapi=newFetchBuilder().withLogger(logger)// That's it.build();// In Service Alogger.info('Starting user flow');// Correlation ID: abc-123constuser=awaitapi('/users/123');// Correlation ID sent as header// In Service B, the correlation ID is automatically extracted and logs are linked.

Credentials are redacted before they reach a log record

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.

Debug production issues faster

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{constresponse=awaitapi('/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"] }// }

📚 Examples

Basic usage

importfetchfrom'@smooai/fetch';// Simple GET requestconstresponse=awaitfetch('https://api.example.com/data');// POST request with JSON body and optionsconstresponse=awaitfetch('https://api.example.com/data',{method: 'POST',headers: {'Content-Type': 'application/json',},body: {key: 'value',},options: {timeout: {timeoutMs: 5000,},retry: {attempts: 3,},},});

(back to examples)

FetchBuilder pattern

The FetchBuilder provides a fluent interface for configuring fetch instances:

import{FetchBuilder,RetryMode}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});constfetch=newFetchBuilder(UserSchema).withTimeout(5000)// 5 second timeout.withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,}).withRateLimit(100,60000)// 100 requests per minute.build();constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }

(back to examples)

Retry

import{FetchBuilder,RetryMode}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},}).build();

(back to examples)

Timeout

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/slow-endpoint',{options: {timeout: {timeoutMs: 5000,},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withTimeout(5000)// 5 second timeout.build();try{constresponse=awaitfetch('https://api.example.com/slow-endpoint');}catch(error){if(errorinstanceofTimeoutError){console.error('Request timed out');}}

(back to examples)

Rate limit

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRateLimit(100,60000,{attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},}).build();

(back to examples)

Schema validation

import{FetchBuilder}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});// Using the default fetchconstresponse=awaitfetch('https://api.example.com/users/123',{options: {schema: UserSchema,},});// Or using FetchBuilderconstfetch=newFetchBuilder(UserSchema).build();try{constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }}catch(error){if(errorinstanceofHumanReadableSchemaError){console.error('Validation failed:',error.message);// Example output:// Validation failed: Invalid email format at path: email}}

(back to examples)

Lifecycle hooks

import{FetchBuilder}from'@smooai/fetch';constapi=newFetchBuilder().withHooks({// Pre-request hook can modify both URL and request configurationpreRequest: (url,init)=>{constmodifiedUrl=newURL(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,});returnresponse;},postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

(back to examples)

Predefined authentication

import{FetchBuilder}from'@smooai/fetch';// Static headers on every requestconstfetch=newFetchBuilder().withInit({headers: {Authorization: 'Bearer your-auth-token','X-API-Key': 'your-api-key',},}).build();// Or a fresh token per request, fetched asynchronouslyconstapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').build();

(back to examples)

Error handling

importfetch,{HTTPResponseError,RatelimitError,RetryError,TimeoutError}from'@smooai/fetch';try{constresponse=awaitfetch('https://api.example.com/data');}catch(error){if(errorinstanceofHTTPResponseError){console.error('HTTP Error:',error.response.status);console.error('Response Data:',error.response.data);}elseif(errorinstanceofRetryError){console.error('Retry failed after all attempts');}elseif(errorinstanceofTimeoutError){console.error('Request timed out');}elseif(errorinstanceofRatelimitError){console.error('Rate limit exceeded');}}

(back to examples)

Built with

  • 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

🧩 Part of Smoo AI

@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.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository and create your branch
  2. Make your changes (the five ports live in src/, python/, rust/, go/, dotnet/)
  3. Add a changeset to document them: pnpm changeset
  4. Open a pull request — reference any related issues

📄 License

MIT © Smoo AI. See LICENSE.


Built by Smoo AI — AI built into every product.

About

Multi-language HTTP client (TypeScript, Python, Rust, Go) with smart retries, circuit breaking, rate limiting, request deduplication, and Standard Schema validation. Built on native fetch for Node.js and browser.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@smooai/fetch — Resilient, type-safe HTTP for real-world APIs

npmPyPIcrates.ioNuGet

Smoo AIlicenseCI

TypeScriptPythonRustGo.NET

retriesRetry-After awarecircuit breakingW3C traceparent

What it is · Feature tour · Install · Quickstart · Language status · Examples · Platform


Stop writing the same retry logic over and over.@smooai/fetch is a drop-in fetch that 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.

What is this?

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-After headers 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 propagationtraceparent headers 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)

Feature tour

Each capability in a few lines of real, current API — snippets are verified against src/ and the language ports, not pseudocode.

CapabilityWhat you get
🔄Smart retriesBackoff + jitter, only on errors worth retrying
🚦Rate-limit respectRetry-After honored to the second, in all five ports
🔌Circuit breakingFail fast when a dependency is down
🎯Typed responsesSchema-validated data, typed end to end
🔗Hooks + authOne place for tokens, logging, and response policy
📡Trace propagationtraceparent on every request, optional OpenTelemetry

🔄 Smart retries

importfetchfrom'@smooai/fetch';// This won't crash if the API is temporarily downconstresponse=awaitfetch('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.

🚦 Rate-limit respect

constresponse=awaitfetch('https://api.github.com/user/repos');// If GitHub says "slow down":// - Sees 429 + Retry-After: 60// - Automatically waits 60 seconds// - Retries and succeeds

All 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.

🔌 Circuit breaking

import{FetchBuilder}from'@smooai/fetch';constcriticalAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50,// open when ≥50% of calls fail…slidingWindowSize: 10,// …across the last 10 callsopenStateDelayMs: 30000,// stay open 30s, then trial a half-open call}).build();try{awaitcriticalAPI('https://payment-processor.com/charge');}catch(error){// Circuit is open — service is down. Show fallback UI immediately.}

🎯 Typed responses + validation

import{z}from'zod';constUserSchema=z.object({id: z.string(),email: z.string().email(),});constresponse=awaitfetch('https://api.example.com/user',{options: {schema: UserSchema},});// response.data is fully typed as { id: string; email: string }// No more runtime surprises in production

In 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.

🔗 Lifecycle hooks + auth

constapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').withHooks({postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

Every port has both seams: an async auth-token provider (fresh token per request, no client rebuild) and pre-request / post-response hooks.

📡 Trace-context propagation

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:awaitapi('https://api.example.com/users/123');// → headers: { traceparent: '00-<trace-id>-<span-id>-01' }// Without it: same request, no traceparent, zero errors.

The request pipeline

%%{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
Loading

📦 Install

LanguagePackageInstall
TypeScript@smooai/fetchpnpm add @smooai/fetch
Pythonsmooai-fetchpip install smooai-fetch
Rustsmooai-fetchcargo add smooai-fetch
Gogithub.com/SmooAI/fetch/go/fetch/v3go get github.com/SmooAI/fetch/go/fetch/v3
.NETSmooAI.Fetchdotnet add package SmooAI.Fetch

Go note: the module path carries the /v3 major suffix Go requires above v1, so the go/fetch/v3.x tags resolve. The import path is github.com/SmooAI/fetch/go/fetch/v3; the package identifier is still fetch. Tags minted before this change (through go/fetch/v3.4.0) point at commits whose go.mod lacked the suffix and will not resolve — use v3.4.1 or later.

Language-specific source lives in src/ (TypeScript), python/, rust/, go/, and dotnet/.

🚀 Quickstart, in your language

It's just fetch, but resilient — retries, timeout, and Retry-After handling are on by default in every port.

TypeScript (full docs)

importfetchfrom'@smooai/fetch';constresponse=awaitfetch('https://api.example.com/users/123');constuser=awaitresponse.json();

Python (python/)

fromsmooai_fetchimportFetchBuilderbuilder=FetchBuilder().with_timeout(5000).with_retry()
response=awaitbuilder.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/)

varfetch=SmooFetch.Create(options =>{options.BaseUrl="https://api.example.com";options.RetryPolicy=RetryPolicy.ExponentialBackoff(maxRetries:3);});varuser=awaitfetch.GetAsync<User>("/users/me");

Node.js and browser (TypeScript)

// Node.jsimportfetchfrom'@smooai/fetch';// Browser — same API, different entry pointimportfetchfrom'@smooai/fetch/browser';constresponse=awaitfetch('/api/checkout',{method: 'POST',body: {items: cart},});

Five languages, honestly

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:

LanguageResponse typing / validationResilience engineHTTP stack
TypeScriptAny Standard Schema validator (Zod, …)mollitianative fetch
PythonPydantic models via with_schema(...)implemented in-packagehttpx
Rustserde — fetch::<T> deserializes into your typeimplemented in-cratereqwest
GoGenerics — fetch.Get[User](...) decodes into your struct, plus an optional RequestOptions.Validate hook returning SchemaValidationErrorimplemented in-packagenet/http
.NETSystem.Text.Json — GetAsync<T> / PostAsync<TReq, TRes> (no pluggable validator)Polly + System.Threading.RateLimitingHttpClient / 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.

Credential redaction is scoped to what each port actually logs

LanguageWhat it logs about a requestRedaction
TypeScriptmethod, host, path, query string, headers, request body, and the URL in the messagefull — headers, query, URL and body
Rustmethod and URL, on one tracing::debug! eventURL only (userinfo password + query params)
Pythonnothingn/a — no logging sink
Gonothingn/a — no logging sink
.NETnothing (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.


📖 Smart defaults

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.

Graceful degradation

constprimaryAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50}).build();constfallbackAPI=newFetchBuilder().withTimeout(2000).build();asyncfunctiongetWeather(city: string){try{returnawaitprimaryAPI(`https://api1.weather.com/${city}`);}catch(error){console.warn('Primary weather API failed, using fallback');returnawaitfallbackAPI(`https://api2.weather.com/${city}`);}}

🔗 Pairs with @smooai/logger

@smooai/fetch works with @smooai/logger for complete observability across distributed systems.

Automatic correlation ID propagation

importfetch,{FetchBuilder}from'@smooai/fetch';import{AwsServerLogger}from'@smooai/logger/AwsServerLogger';constlogger=newAwsServerLogger({name: 'APIClient'});constapi=newFetchBuilder().withLogger(logger)// That's it.build();// In Service Alogger.info('Starting user flow');// Correlation ID: abc-123constuser=awaitapi('/users/123');// Correlation ID sent as header// In Service B, the correlation ID is automatically extracted and logs are linked.

Credentials are redacted before they reach a log record

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.

Debug production issues faster

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{constresponse=awaitapi('/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"] }// }

📚 Examples

Basic usage

importfetchfrom'@smooai/fetch';// Simple GET requestconstresponse=awaitfetch('https://api.example.com/data');// POST request with JSON body and optionsconstresponse=awaitfetch('https://api.example.com/data',{method: 'POST',headers: {'Content-Type': 'application/json',},body: {key: 'value',},options: {timeout: {timeoutMs: 5000,},retry: {attempts: 3,},},});

(back to examples)

FetchBuilder pattern

The FetchBuilder provides a fluent interface for configuring fetch instances:

import{FetchBuilder,RetryMode}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});constfetch=newFetchBuilder(UserSchema).withTimeout(5000)// 5 second timeout.withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,}).withRateLimit(100,60000)// 100 requests per minute.build();constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }

(back to examples)

Retry

import{FetchBuilder,RetryMode}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},}).build();

(back to examples)

Timeout

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/slow-endpoint',{options: {timeout: {timeoutMs: 5000,},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withTimeout(5000)// 5 second timeout.build();try{constresponse=awaitfetch('https://api.example.com/slow-endpoint');}catch(error){if(errorinstanceofTimeoutError){console.error('Request timed out');}}

(back to examples)

Rate limit

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRateLimit(100,60000,{attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},}).build();

(back to examples)

Schema validation

import{FetchBuilder}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});// Using the default fetchconstresponse=awaitfetch('https://api.example.com/users/123',{options: {schema: UserSchema,},});// Or using FetchBuilderconstfetch=newFetchBuilder(UserSchema).build();try{constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }}catch(error){if(errorinstanceofHumanReadableSchemaError){console.error('Validation failed:',error.message);// Example output:// Validation failed: Invalid email format at path: email}}

(back to examples)

Lifecycle hooks

import{FetchBuilder}from'@smooai/fetch';constapi=newFetchBuilder().withHooks({// Pre-request hook can modify both URL and request configurationpreRequest: (url,init)=>{constmodifiedUrl=newURL(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,});returnresponse;},postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

(back to examples)

Predefined authentication

import{FetchBuilder}from'@smooai/fetch';// Static headers on every requestconstfetch=newFetchBuilder().withInit({headers: {Authorization: 'Bearer your-auth-token','X-API-Key': 'your-api-key',},}).build();// Or a fresh token per request, fetched asynchronouslyconstapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').build();

(back to examples)

Error handling

importfetch,{HTTPResponseError,RatelimitError,RetryError,TimeoutError}from'@smooai/fetch';try{constresponse=awaitfetch('https://api.example.com/data');}catch(error){if(errorinstanceofHTTPResponseError){console.error('HTTP Error:',error.response.status);console.error('Response Data:',error.response.data);}elseif(errorinstanceofRetryError){console.error('Retry failed after all attempts');}elseif(errorinstanceofTimeoutError){console.error('Request timed out');}elseif(errorinstanceofRatelimitError){console.error('Rate limit exceeded');}}

(back to examples)

Built with

  • 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

🧩 Part of Smoo AI

@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.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository and create your branch
  2. Make your changes (the five ports live in src/, python/, rust/, go/, dotnet/)
  3. Add a changeset to document them: pnpm changeset
  4. Open a pull request — reference any related issues

📄 License

MIT © Smoo AI. See LICENSE.


Built by Smoo AI — AI built into every product.

About

Multi-language HTTP client (TypeScript, Python, Rust, Go) with smart retries, circuit breaking, rate limiting, request deduplication, and Standard Schema validation. Built on native fetch for Node.js and browser.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@smooai/fetch — Resilient, type-safe HTTP for real-world APIs

npmPyPIcrates.ioNuGet

Smoo AIlicenseCI

TypeScriptPythonRustGo.NET

retriesRetry-After awarecircuit breakingW3C traceparent

What it is · Feature tour · Install · Quickstart · Language status · Examples · Platform


Stop writing the same retry logic over and over.@smooai/fetch is a drop-in fetch that 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.

What is this?

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-After headers 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 propagationtraceparent headers 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)

Feature tour

Each capability in a few lines of real, current API — snippets are verified against src/ and the language ports, not pseudocode.

CapabilityWhat you get
🔄Smart retriesBackoff + jitter, only on errors worth retrying
🚦Rate-limit respectRetry-After honored to the second, in all five ports
🔌Circuit breakingFail fast when a dependency is down
🎯Typed responsesSchema-validated data, typed end to end
🔗Hooks + authOne place for tokens, logging, and response policy
📡Trace propagationtraceparent on every request, optional OpenTelemetry

🔄 Smart retries

importfetchfrom'@smooai/fetch';// This won't crash if the API is temporarily downconstresponse=awaitfetch('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.

🚦 Rate-limit respect

constresponse=awaitfetch('https://api.github.com/user/repos');// If GitHub says "slow down":// - Sees 429 + Retry-After: 60// - Automatically waits 60 seconds// - Retries and succeeds

All 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.

🔌 Circuit breaking

import{FetchBuilder}from'@smooai/fetch';constcriticalAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50,// open when ≥50% of calls fail…slidingWindowSize: 10,// …across the last 10 callsopenStateDelayMs: 30000,// stay open 30s, then trial a half-open call}).build();try{awaitcriticalAPI('https://payment-processor.com/charge');}catch(error){// Circuit is open — service is down. Show fallback UI immediately.}

🎯 Typed responses + validation

import{z}from'zod';constUserSchema=z.object({id: z.string(),email: z.string().email(),});constresponse=awaitfetch('https://api.example.com/user',{options: {schema: UserSchema},});// response.data is fully typed as { id: string; email: string }// No more runtime surprises in production

In 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.

🔗 Lifecycle hooks + auth

constapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').withHooks({postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

Every port has both seams: an async auth-token provider (fresh token per request, no client rebuild) and pre-request / post-response hooks.

📡 Trace-context propagation

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:awaitapi('https://api.example.com/users/123');// → headers: { traceparent: '00-<trace-id>-<span-id>-01' }// Without it: same request, no traceparent, zero errors.

The request pipeline

%%{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
Loading

📦 Install

LanguagePackageInstall
TypeScript@smooai/fetchpnpm add @smooai/fetch
Pythonsmooai-fetchpip install smooai-fetch
Rustsmooai-fetchcargo add smooai-fetch
Gogithub.com/SmooAI/fetch/go/fetch/v3go get github.com/SmooAI/fetch/go/fetch/v3
.NETSmooAI.Fetchdotnet add package SmooAI.Fetch

Go note: the module path carries the /v3 major suffix Go requires above v1, so the go/fetch/v3.x tags resolve. The import path is github.com/SmooAI/fetch/go/fetch/v3; the package identifier is still fetch. Tags minted before this change (through go/fetch/v3.4.0) point at commits whose go.mod lacked the suffix and will not resolve — use v3.4.1 or later.

Language-specific source lives in src/ (TypeScript), python/, rust/, go/, and dotnet/.

🚀 Quickstart, in your language

It's just fetch, but resilient — retries, timeout, and Retry-After handling are on by default in every port.

TypeScript (full docs)

importfetchfrom'@smooai/fetch';constresponse=awaitfetch('https://api.example.com/users/123');constuser=awaitresponse.json();

Python (python/)

fromsmooai_fetchimportFetchBuilderbuilder=FetchBuilder().with_timeout(5000).with_retry()
response=awaitbuilder.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/)

varfetch=SmooFetch.Create(options =>{options.BaseUrl="https://api.example.com";options.RetryPolicy=RetryPolicy.ExponentialBackoff(maxRetries:3);});varuser=awaitfetch.GetAsync<User>("/users/me");

Node.js and browser (TypeScript)

// Node.jsimportfetchfrom'@smooai/fetch';// Browser — same API, different entry pointimportfetchfrom'@smooai/fetch/browser';constresponse=awaitfetch('/api/checkout',{method: 'POST',body: {items: cart},});

Five languages, honestly

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:

LanguageResponse typing / validationResilience engineHTTP stack
TypeScriptAny Standard Schema validator (Zod, …)mollitianative fetch
PythonPydantic models via with_schema(...)implemented in-packagehttpx
Rustserde — fetch::<T> deserializes into your typeimplemented in-cratereqwest
GoGenerics — fetch.Get[User](...) decodes into your struct, plus an optional RequestOptions.Validate hook returning SchemaValidationErrorimplemented in-packagenet/http
.NETSystem.Text.Json — GetAsync<T> / PostAsync<TReq, TRes> (no pluggable validator)Polly + System.Threading.RateLimitingHttpClient / 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.

Credential redaction is scoped to what each port actually logs

LanguageWhat it logs about a requestRedaction
TypeScriptmethod, host, path, query string, headers, request body, and the URL in the messagefull — headers, query, URL and body
Rustmethod and URL, on one tracing::debug! eventURL only (userinfo password + query params)
Pythonnothingn/a — no logging sink
Gonothingn/a — no logging sink
.NETnothing (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.


📖 Smart defaults

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.

Graceful degradation

constprimaryAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50}).build();constfallbackAPI=newFetchBuilder().withTimeout(2000).build();asyncfunctiongetWeather(city: string){try{returnawaitprimaryAPI(`https://api1.weather.com/${city}`);}catch(error){console.warn('Primary weather API failed, using fallback');returnawaitfallbackAPI(`https://api2.weather.com/${city}`);}}

🔗 Pairs with @smooai/logger

@smooai/fetch works with @smooai/logger for complete observability across distributed systems.

Automatic correlation ID propagation

importfetch,{FetchBuilder}from'@smooai/fetch';import{AwsServerLogger}from'@smooai/logger/AwsServerLogger';constlogger=newAwsServerLogger({name: 'APIClient'});constapi=newFetchBuilder().withLogger(logger)// That's it.build();// In Service Alogger.info('Starting user flow');// Correlation ID: abc-123constuser=awaitapi('/users/123');// Correlation ID sent as header// In Service B, the correlation ID is automatically extracted and logs are linked.

Credentials are redacted before they reach a log record

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.

Debug production issues faster

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{constresponse=awaitapi('/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"] }// }

📚 Examples

Basic usage

importfetchfrom'@smooai/fetch';// Simple GET requestconstresponse=awaitfetch('https://api.example.com/data');// POST request with JSON body and optionsconstresponse=awaitfetch('https://api.example.com/data',{method: 'POST',headers: {'Content-Type': 'application/json',},body: {key: 'value',},options: {timeout: {timeoutMs: 5000,},retry: {attempts: 3,},},});

(back to examples)

FetchBuilder pattern

The FetchBuilder provides a fluent interface for configuring fetch instances:

import{FetchBuilder,RetryMode}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});constfetch=newFetchBuilder(UserSchema).withTimeout(5000)// 5 second timeout.withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,}).withRateLimit(100,60000)// 100 requests per minute.build();constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }

(back to examples)

Retry

import{FetchBuilder,RetryMode}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},}).build();

(back to examples)

Timeout

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/slow-endpoint',{options: {timeout: {timeoutMs: 5000,},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withTimeout(5000)// 5 second timeout.build();try{constresponse=awaitfetch('https://api.example.com/slow-endpoint');}catch(error){if(errorinstanceofTimeoutError){console.error('Request timed out');}}

(back to examples)

Rate limit

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRateLimit(100,60000,{attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},}).build();

(back to examples)

Schema validation

import{FetchBuilder}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});// Using the default fetchconstresponse=awaitfetch('https://api.example.com/users/123',{options: {schema: UserSchema,},});// Or using FetchBuilderconstfetch=newFetchBuilder(UserSchema).build();try{constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }}catch(error){if(errorinstanceofHumanReadableSchemaError){console.error('Validation failed:',error.message);// Example output:// Validation failed: Invalid email format at path: email}}

(back to examples)

Lifecycle hooks

import{FetchBuilder}from'@smooai/fetch';constapi=newFetchBuilder().withHooks({// Pre-request hook can modify both URL and request configurationpreRequest: (url,init)=>{constmodifiedUrl=newURL(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,});returnresponse;},postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

(back to examples)

Predefined authentication

import{FetchBuilder}from'@smooai/fetch';// Static headers on every requestconstfetch=newFetchBuilder().withInit({headers: {Authorization: 'Bearer your-auth-token','X-API-Key': 'your-api-key',},}).build();// Or a fresh token per request, fetched asynchronouslyconstapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').build();

(back to examples)

Error handling

importfetch,{HTTPResponseError,RatelimitError,RetryError,TimeoutError}from'@smooai/fetch';try{constresponse=awaitfetch('https://api.example.com/data');}catch(error){if(errorinstanceofHTTPResponseError){console.error('HTTP Error:',error.response.status);console.error('Response Data:',error.response.data);}elseif(errorinstanceofRetryError){console.error('Retry failed after all attempts');}elseif(errorinstanceofTimeoutError){console.error('Request timed out');}elseif(errorinstanceofRatelimitError){console.error('Rate limit exceeded');}}

(back to examples)

Built with

  • 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

🧩 Part of Smoo AI

@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.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository and create your branch
  2. Make your changes (the five ports live in src/, python/, rust/, go/, dotnet/)
  3. Add a changeset to document them: pnpm changeset
  4. Open a pull request — reference any related issues

📄 License

MIT © Smoo AI. See LICENSE.


Built by Smoo AI — AI built into every product.

About

Multi-language HTTP client (TypeScript, Python, Rust, Go) with smart retries, circuit breaking, rate limiting, request deduplication, and Standard Schema validation. Built on native fetch for Node.js and browser.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

@smooai/fetch — Resilient, type-safe HTTP for real-world APIs

npmPyPIcrates.ioNuGet

Smoo AIlicenseCI

TypeScriptPythonRustGo.NET

retriesRetry-After awarecircuit breakingW3C traceparent

What it is · Feature tour · Install · Quickstart · Language status · Examples · Platform


Stop writing the same retry logic over and over.@smooai/fetch is a drop-in fetch that 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.

What is this?

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-After headers 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 propagationtraceparent headers 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)

Feature tour

Each capability in a few lines of real, current API — snippets are verified against src/ and the language ports, not pseudocode.

CapabilityWhat you get
🔄Smart retriesBackoff + jitter, only on errors worth retrying
🚦Rate-limit respectRetry-After honored to the second, in all five ports
🔌Circuit breakingFail fast when a dependency is down
🎯Typed responsesSchema-validated data, typed end to end
🔗Hooks + authOne place for tokens, logging, and response policy
📡Trace propagationtraceparent on every request, optional OpenTelemetry

🔄 Smart retries

importfetchfrom'@smooai/fetch';// This won't crash if the API is temporarily downconstresponse=awaitfetch('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.

🚦 Rate-limit respect

constresponse=awaitfetch('https://api.github.com/user/repos');// If GitHub says "slow down":// - Sees 429 + Retry-After: 60// - Automatically waits 60 seconds// - Retries and succeeds

All 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.

🔌 Circuit breaking

import{FetchBuilder}from'@smooai/fetch';constcriticalAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50,// open when ≥50% of calls fail…slidingWindowSize: 10,// …across the last 10 callsopenStateDelayMs: 30000,// stay open 30s, then trial a half-open call}).build();try{awaitcriticalAPI('https://payment-processor.com/charge');}catch(error){// Circuit is open — service is down. Show fallback UI immediately.}

🎯 Typed responses + validation

import{z}from'zod';constUserSchema=z.object({id: z.string(),email: z.string().email(),});constresponse=awaitfetch('https://api.example.com/user',{options: {schema: UserSchema},});// response.data is fully typed as { id: string; email: string }// No more runtime surprises in production

In 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.

🔗 Lifecycle hooks + auth

constapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').withHooks({postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

Every port has both seams: an async auth-token provider (fresh token per request, no client rebuild) and pre-request / post-response hooks.

📡 Trace-context propagation

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:awaitapi('https://api.example.com/users/123');// → headers: { traceparent: '00-<trace-id>-<span-id>-01' }// Without it: same request, no traceparent, zero errors.

The request pipeline

%%{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
Loading

📦 Install

LanguagePackageInstall
TypeScript@smooai/fetchpnpm add @smooai/fetch
Pythonsmooai-fetchpip install smooai-fetch
Rustsmooai-fetchcargo add smooai-fetch
Gogithub.com/SmooAI/fetch/go/fetch/v3go get github.com/SmooAI/fetch/go/fetch/v3
.NETSmooAI.Fetchdotnet add package SmooAI.Fetch

Go note: the module path carries the /v3 major suffix Go requires above v1, so the go/fetch/v3.x tags resolve. The import path is github.com/SmooAI/fetch/go/fetch/v3; the package identifier is still fetch. Tags minted before this change (through go/fetch/v3.4.0) point at commits whose go.mod lacked the suffix and will not resolve — use v3.4.1 or later.

Language-specific source lives in src/ (TypeScript), python/, rust/, go/, and dotnet/.

🚀 Quickstart, in your language

It's just fetch, but resilient — retries, timeout, and Retry-After handling are on by default in every port.

TypeScript (full docs)

importfetchfrom'@smooai/fetch';constresponse=awaitfetch('https://api.example.com/users/123');constuser=awaitresponse.json();

Python (python/)

fromsmooai_fetchimportFetchBuilderbuilder=FetchBuilder().with_timeout(5000).with_retry()
response=awaitbuilder.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/)

varfetch=SmooFetch.Create(options =>{options.BaseUrl="https://api.example.com";options.RetryPolicy=RetryPolicy.ExponentialBackoff(maxRetries:3);});varuser=awaitfetch.GetAsync<User>("/users/me");

Node.js and browser (TypeScript)

// Node.jsimportfetchfrom'@smooai/fetch';// Browser — same API, different entry pointimportfetchfrom'@smooai/fetch/browser';constresponse=awaitfetch('/api/checkout',{method: 'POST',body: {items: cart},});

Five languages, honestly

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:

LanguageResponse typing / validationResilience engineHTTP stack
TypeScriptAny Standard Schema validator (Zod, …)mollitianative fetch
PythonPydantic models via with_schema(...)implemented in-packagehttpx
Rustserde — fetch::<T> deserializes into your typeimplemented in-cratereqwest
GoGenerics — fetch.Get[User](...) decodes into your struct, plus an optional RequestOptions.Validate hook returning SchemaValidationErrorimplemented in-packagenet/http
.NETSystem.Text.Json — GetAsync<T> / PostAsync<TReq, TRes> (no pluggable validator)Polly + System.Threading.RateLimitingHttpClient / 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.

Credential redaction is scoped to what each port actually logs

LanguageWhat it logs about a requestRedaction
TypeScriptmethod, host, path, query string, headers, request body, and the URL in the messagefull — headers, query, URL and body
Rustmethod and URL, on one tracing::debug! eventURL only (userinfo password + query params)
Pythonnothingn/a — no logging sink
Gonothingn/a — no logging sink
.NETnothing (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.


📖 Smart defaults

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.

Graceful degradation

constprimaryAPI=newFetchBuilder().withCircuitBreaker({failureRateThreshold: 50}).build();constfallbackAPI=newFetchBuilder().withTimeout(2000).build();asyncfunctiongetWeather(city: string){try{returnawaitprimaryAPI(`https://api1.weather.com/${city}`);}catch(error){console.warn('Primary weather API failed, using fallback');returnawaitfallbackAPI(`https://api2.weather.com/${city}`);}}

🔗 Pairs with @smooai/logger

@smooai/fetch works with @smooai/logger for complete observability across distributed systems.

Automatic correlation ID propagation

importfetch,{FetchBuilder}from'@smooai/fetch';import{AwsServerLogger}from'@smooai/logger/AwsServerLogger';constlogger=newAwsServerLogger({name: 'APIClient'});constapi=newFetchBuilder().withLogger(logger)// That's it.build();// In Service Alogger.info('Starting user flow');// Correlation ID: abc-123constuser=awaitapi('/users/123');// Correlation ID sent as header// In Service B, the correlation ID is automatically extracted and logs are linked.

Credentials are redacted before they reach a log record

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.

Debug production issues faster

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{constresponse=awaitapi('/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"] }// }

📚 Examples

Basic usage

importfetchfrom'@smooai/fetch';// Simple GET requestconstresponse=awaitfetch('https://api.example.com/data');// POST request with JSON body and optionsconstresponse=awaitfetch('https://api.example.com/data',{method: 'POST',headers: {'Content-Type': 'application/json',},body: {key: 'value',},options: {timeout: {timeoutMs: 5000,},retry: {attempts: 3,},},});

(back to examples)

FetchBuilder pattern

The FetchBuilder provides a fluent interface for configuring fetch instances:

import{FetchBuilder,RetryMode}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});constfetch=newFetchBuilder(UserSchema).withTimeout(5000)// 5 second timeout.withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,}).withRateLimit(100,60000)// 100 requests per minute.build();constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }

(back to examples)

Retry

import{FetchBuilder,RetryMode}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRetry({attempts: 3,initialIntervalMs: 1000,mode: RetryMode.JITTER,factor: 2,jitterAdjustment: 0.5,onRejection: (error)=>{if(errorinstanceofHTTPResponseError){returnerror.response.status>=500;}returnfalse;},}).build();

(back to examples)

Timeout

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/slow-endpoint',{options: {timeout: {timeoutMs: 5000,},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withTimeout(5000)// 5 second timeout.build();try{constresponse=awaitfetch('https://api.example.com/slow-endpoint');}catch(error){if(errorinstanceofTimeoutError){console.error('Request timed out');}}

(back to examples)

Rate limit

import{FetchBuilder}from'@smooai/fetch';// Using the default fetchconstresponse=awaitfetch('https://api.example.com/data',{options: {retry: {attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},},},});// Or using FetchBuilderconstfetch=newFetchBuilder().withRateLimit(100,60000,{attempts: 1,initialIntervalMs: 1000,onRejection: (error)=>{if(errorinstanceofRatelimitError){returnerror.remainingTimeInRatelimit;}returnfalse;},}).build();

(back to examples)

Schema validation

import{FetchBuilder}from'@smooai/fetch';import{z}from'zod';constUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),});// Using the default fetchconstresponse=awaitfetch('https://api.example.com/users/123',{options: {schema: UserSchema,},});// Or using FetchBuilderconstfetch=newFetchBuilder(UserSchema).build();try{constresponse=awaitfetch('https://api.example.com/users/123');// response.data is typed as { id: string; name: string; email: string }}catch(error){if(errorinstanceofHumanReadableSchemaError){console.error('Validation failed:',error.message);// Example output:// Validation failed: Invalid email format at path: email}}

(back to examples)

Lifecycle hooks

import{FetchBuilder}from'@smooai/fetch';constapi=newFetchBuilder().withHooks({// Pre-request hook can modify both URL and request configurationpreRequest: (url,init)=>{constmodifiedUrl=newURL(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,});returnresponse;},postResponseError: (url,init,error)=>{if(error.response?.status===401){refreshToken();// Token expired — refresh and retry}returnerror;},}).build();

(back to examples)

Predefined authentication

import{FetchBuilder}from'@smooai/fetch';// Static headers on every requestconstfetch=newFetchBuilder().withInit({headers: {Authorization: 'Bearer your-auth-token','X-API-Key': 'your-api-key',},}).build();// Or a fresh token per request, fetched asynchronouslyconstapi=newFetchBuilder().withAuthTokenProvider(async()=>awaittokenStore.getFreshToken(),'Bearer').build();

(back to examples)

Error handling

importfetch,{HTTPResponseError,RatelimitError,RetryError,TimeoutError}from'@smooai/fetch';try{constresponse=awaitfetch('https://api.example.com/data');}catch(error){if(errorinstanceofHTTPResponseError){console.error('HTTP Error:',error.response.status);console.error('Response Data:',error.response.data);}elseif(errorinstanceofRetryError){console.error('Retry failed after all attempts');}elseif(errorinstanceofTimeoutError){console.error('Request timed out');}elseif(errorinstanceofRatelimitError){console.error('Rate limit exceeded');}}

(back to examples)

Built with

  • 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

🧩 Part of Smoo AI

@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.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository and create your branch
  2. Make your changes (the five ports live in src/, python/, rust/, go/, dotnet/)
  3. Add a changeset to document them: pnpm changeset
  4. Open a pull request — reference any related issues

📄 License

MIT © Smoo AI. See LICENSE.


Built by Smoo AI — AI built into every product.

About

Multi-language HTTP client (TypeScript, Python, Rust, Go) with smart retries, circuit breaking, rate limiting, request deduplication, and Standard Schema validation. Built on native fetch for Node.js and browser.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages