Tiny and elegant HTTP client based on the Fetch API
—
Pending
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Pending
The risk profile of this skill
Extensible lifecycle hooks for request modification, response processing, error handling, and retry customization. Hooks enable powerful middleware-like functionality for request/response transformation.
Configure lifecycle hooks that run at different stages of the request process.
interface Hooks {
/** Modify request before sending */
beforeRequest?: BeforeRequestHook[];
/** Modify request before retry attempts */
beforeRetry?: BeforeRetryHook[];
/** Process response after receiving */
afterResponse?: AfterResponseHook[];
/** Modify HTTPError before throwing */
beforeError?: BeforeErrorHook[];
}
type BeforeRequestHook = (
request: KyRequest,
options: NormalizedOptions
) => Request | Response | void | Promise<Request | Response | void>;
type BeforeRetryState = {
request: KyRequest;
options: NormalizedOptions;
error: Error;
retryCount: number;
};
type BeforeRetryHook = (options: BeforeRetryState) => typeof stop | void | Promise<typeof stop | void>;
type AfterResponseHook = (
request: KyRequest,
options: NormalizedOptions,
response: KyResponse
) => Response | void | Promise<Response | void>;
type BeforeErrorHook = (error: HTTPError) => HTTPError | Promise<HTTPError>;Modify requests before they are sent, or provide cached responses.
type BeforeRequestHook = (
request: KyRequest,
options: NormalizedOptions
) => Request | Response | void | Promise<Request | Response | void>;Usage Examples:
import ky from "ky";
// Add authentication headers
const authClient = ky.create({
hooks: {
beforeRequest: [
(request) => {
const token = localStorage.getItem("authToken");
if (token) {
request.headers.set("Authorization", `Bearer ${token}`);
}
}
]
}
});
// Add request logging
const loggedClient = ky.create({
hooks: {
beforeRequest: [
(request, options) => {
console.log(`→ ${request.method} ${request.url}`);
console.log("Headers:", Object.fromEntries(request.headers));
if (options.json) {
console.log("JSON Body:", options.json);
}
}
]
}
});
// Request transformation
const transformClient = ky.create({
hooks: {
beforeRequest: [
(request) => {
// Add API version header
request.headers.set("API-Version", "2.0");
// Add request ID for tracing
request.headers.set("X-Request-ID", crypto.randomUUID());
// Add client information
request.headers.set("User-Agent", "MyApp/1.0");
}
]
}
});
// Return cached response
const cacheClient = ky.create({
hooks: {
beforeRequest: [
async (request) => {
const cacheKey = `${request.method}:${request.url}`;
const cached = localStorage.getItem(cacheKey);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
const age = Date.now() - timestamp;
// Use cache if less than 5 minutes old
if (age < 5 * 60 * 1000) {
return new Response(JSON.stringify(data), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
}
}
]
}
});
// Modify request based on conditions
const conditionalClient = ky.create({
hooks: {
beforeRequest: [
(request, options) => {
// Add compression header for large requests
if (options.json && JSON.stringify(options.json).length > 1000) {
request.headers.set("Accept-Encoding", "gzip, deflate, br");
}
// Switch to alternative endpoint for specific paths
if (request.url.includes("/v1/")) {
const newUrl = request.url.replace("/v1/", "/v2/");
return new Request(newUrl, request);
}
}
]
}
});Customize retry behavior and modify requests before retry attempts.
interface BeforeRetryState {
request: KyRequest;
options: NormalizedOptions;
error: Error;
retryCount: number;
}
type BeforeRetryHook = (state: BeforeRetryState) => typeof stop | void | Promise<typeof stop | void>;Usage Examples:
import ky from "ky";
// Token refresh on authentication errors
const authRetryClient = ky.create({
hooks: {
beforeRetry: [
async ({ request, options, error, retryCount }) => {
if (error instanceof HTTPError && error.response.status === 401) {
console.log(`Authentication failed, refreshing token (attempt ${retryCount})`);
try {
const newToken = await refreshAuthToken();
request.headers.set("Authorization", `Bearer ${newToken}`);
localStorage.setItem("authToken", newToken);
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
return ky.stop; // Stop retrying
}
}
}
]
}
});
// Intelligent retry decisions
const smartRetryClient = ky.create({
hooks: {
beforeRetry: [
async ({ request, options, error, retryCount }) => {
console.log(`Retry attempt ${retryCount} for ${request.method} ${request.url}`);
// Stop retrying after business hours for non-critical requests
const now = new Date();
const hour = now.getHours();
const isBusinessHours = hour >= 9 && hour < 17;
if (!isBusinessHours && retryCount > 2) {
console.log("Outside business hours, stopping retries");
return ky.stop;
}
// Check system health before retrying
if (error instanceof HTTPError && error.response.status >= 500) {
try {
const healthCheck = await ky.get("https://api.example.com/health", {
timeout: 2000
});
if (!healthCheck.ok) {
console.log("System unhealthy, stopping retries");
return ky.stop;
}
} catch {
console.log("Health check failed, stopping retries");
return ky.stop;
}
}
}
]
}
});
// Dynamic request modification
const dynamicRetryClient = ky.create({
hooks: {
beforeRetry: [
({ request, error, retryCount }) => {
// Reduce timeout on retry
if (retryCount > 1) {
const newTimeout = Math.max(5000 - (retryCount * 1000), 1000);
// Note: Can't modify timeout here, but can log strategy
console.log(`Retry ${retryCount}: would use timeout ${newTimeout}ms`);
}
// Switch to backup endpoint
if (retryCount > 2 && request.url.includes("api.example.com")) {
const backupUrl = request.url.replace("api.example.com", "backup-api.example.com");
// Create new request with backup URL
Object.defineProperty(request, "url", { value: backupUrl });
}
// Add retry metadata
request.headers.set("X-Retry-Count", retryCount.toString());
request.headers.set("X-Original-Error", error.message);
}
]
}
});
// Circuit breaker pattern
let failureCount = 0;
let lastFailureTime = 0;
const CIRCUIT_BREAKER_THRESHOLD = 5;
const CIRCUIT_BREAKER_TIMEOUT = 60000; // 1 minute
const circuitBreakerClient = ky.create({
hooks: {
beforeRetry: [
({ error, retryCount }) => {
const now = Date.now();
// Reset failure count after timeout
if (now - lastFailureTime > CIRCUIT_BREAKER_TIMEOUT) {
failureCount = 0;
}
// Increment failure count
if (error instanceof HTTPError && error.response.status >= 500) {
failureCount++;
lastFailureTime = now;
}
// Stop retrying if circuit breaker is open
if (failureCount >= CIRCUIT_BREAKER_THRESHOLD) {
console.log("Circuit breaker open, stopping retries");
return ky.stop;
}
}
]
}
});Process and potentially modify responses after they are received.
type AfterResponseHook = (
request: KyRequest,
options: NormalizedOptions,
response: KyResponse
) => Response | void | Promise<Response | void>;Usage Examples:
import ky from "ky";
// Response logging
const loggedClient = ky.create({
hooks: {
afterResponse: [
(request, options, response) => {
console.log(`← ${response.status} ${request.method} ${request.url}`);
console.log("Response Headers:", Object.fromEntries(response.headers));
// Log timing information
const duration = Date.now() - (request as any).startTime;
console.log(`Duration: ${duration}ms`);
}
]
}
});
// Response caching
const cachingClient = ky.create({
hooks: {
afterResponse: [
async (request, options, response) => {
// Only cache successful GET requests
if (request.method === "GET" && response.ok) {
const cacheKey = `${request.method}:${request.url}`;
const data = await response.clone().json();
localStorage.setItem(cacheKey, JSON.stringify({
data,
timestamp: Date.now(),
headers: Object.fromEntries(response.headers)
}));
}
}
]
}
});
// Response transformation
const transformResponseClient = ky.create({
hooks: {
afterResponse: [
async (request, options, response) => {
// Unwrap API responses
if (response.headers.get("content-type")?.includes("application/json")) {
const data = await response.clone().json();
// If response has a "data" wrapper, unwrap it
if (data && typeof data === "object" && "data" in data) {
return new Response(JSON.stringify(data.data), {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
}
}
}
]
}
});
// Automatic retry on specific conditions
const conditionalRetryClient = ky.create({
hooks: {
afterResponse: [
async (request, options, response) => {
// Retry on 403 with token refresh
if (response.status === 403) {
const newToken = await refreshAuthToken();
// Create new request with fresh token
const newRequest = new Request(request, {
headers: {
...Object.fromEntries(request.headers),
"Authorization": `Bearer ${newToken}`
}
});
// Retry the request
return ky(newRequest, options);
}
}
]
}
});
// Response monitoring and metrics
const metricsClient = ky.create({
hooks: {
afterResponse: [
(request, options, response) => {
// Send metrics to monitoring service
const metrics = {
method: request.method,
url: request.url,
status: response.status,
duration: Date.now() - (request as any).startTime,
size: response.headers.get("content-length") || 0
};
// Send to analytics (non-blocking)
sendMetrics(metrics).catch(console.warn);
// Update performance counters
updatePerformanceCounters(metrics);
}
]
}
});Modify HTTPError objects before they are thrown.
type BeforeErrorHook = (error: HTTPError) => HTTPError | Promise<HTTPError>;Usage Examples:
import ky from "ky";
// Enhanced error information
const enhancedErrorClient = ky.create({
hooks: {
beforeError: [
async (error) => {
// Add response body to error for debugging
if (error.response.body) {
try {
const responseText = await error.response.clone().text();
error.message += `\nResponse body: ${responseText}`;
} catch {
// Ignore if body can't be read
}
}
// Add request information
error.message += `\nRequest: ${error.request.method} ${error.request.url}`;
// Add timestamp
error.message += `\nTimestamp: ${new Date().toISOString()}`;
return error;
}
]
}
});
// Custom error types
class APIError extends Error {
constructor(
message: string,
public code: string,
public status: number
) {
super(message);
this.name = "APIError";
}
}
const customErrorClient = ky.create({
hooks: {
beforeError: [
async (error) => {
try {
const errorData = await error.response.clone().json();
// Transform to custom error type
if (errorData.code && errorData.message) {
const customError = new APIError(
errorData.message,
errorData.code,
error.response.status
);
// Copy properties from original error
(customError as any).response = error.response;
(customError as any).request = error.request;
(customError as any).options = error.options;
return customError as any;
}
} catch {
// Fall back to original error if parsing fails
}
return error;
}
]
}
});
// Error reporting and logging
const reportingClient = ky.create({
hooks: {
beforeError: [
(error) => {
// Log error details
console.error("HTTP Error:", {
method: error.request.method,
url: error.request.url,
status: error.response.status,
statusText: error.response.statusText,
message: error.message
});
// Report to error tracking service
if (error.response.status >= 500) {
reportError({
type: "http_error",
status: error.response.status,
url: error.request.url,
method: error.request.method,
message: error.message,
timestamp: new Date().toISOString()
});
}
return error;
}
]
}
});
// User-friendly error messages
const userFriendlyClient = ky.create({
hooks: {
beforeError: [
(error) => {
// Map status codes to user-friendly messages
const userMessages: Record<number, string> = {
400: "The request was invalid. Please check your input.",
401: "Authentication required. Please log in.",
403: "You don't have permission to access this resource.",
404: "The requested resource was not found.",
429: "Too many requests. Please try again later.",
500: "Server error. Please try again later.",
502: "Service temporarily unavailable.",
503: "Service under maintenance. Please try again later."
};
const userMessage = userMessages[error.response.status];
if (userMessage) {
error.message = userMessage;
}
return error;
}
]
}
});Chain multiple hooks for complex request/response processing.
Usage Examples:
import ky from "ky";
// Multiple hooks in sequence
const multiHookClient = ky.create({
hooks: {
beforeRequest: [
// 1. Add authentication
(request) => {
const token = getAuthToken();
if (token) {
request.headers.set("Authorization", `Bearer ${token}`);
}
},
// 2. Add tracing
(request) => {
request.headers.set("X-Trace-ID", generateTraceId());
},
// 3. Add timing
(request) => {
(request as any).startTime = Date.now();
}
],
afterResponse: [
// 1. Log response
(request, options, response) => {
const duration = Date.now() - (request as any).startTime;
console.log(`${request.method} ${request.url} - ${response.status} (${duration}ms)`);
},
// 2. Cache if appropriate
async (request, options, response) => {
if (request.method === "GET" && response.ok) {
await cacheResponse(request.url, response.clone());
}
},
// 3. Update metrics
(request, options, response) => {
updateRequestMetrics({
method: request.method,
status: response.status,
duration: Date.now() - (request as any).startTime
});
}
]
}
});interface Hooks {
beforeRequest?: BeforeRequestHook[];
beforeRetry?: BeforeRetryHook[];
afterResponse?: AfterResponseHook[];
beforeError?: BeforeErrorHook[];
}
type BeforeRequestHook = (
request: KyRequest,
options: NormalizedOptions
) => Request | Response | void | Promise<Request | Response | void>;
interface BeforeRetryState {
request: KyRequest;
options: NormalizedOptions;
error: Error;
retryCount: number;
}
type BeforeRetryHook = (state: BeforeRetryState) => typeof stop | void | Promise<typeof stop | void>;
type AfterResponseHook = (
request: KyRequest,
options: NormalizedOptions,
response: KyResponse
) => Response | void | Promise<Response | void>;
type BeforeErrorHook = (error: HTTPError) => HTTPError | Promise<HTTPError>;
// Special symbol for stopping retries
declare const stop: unique symbol;