Thunder
Thunder is Zeus’s custom fetch client that gives you full control over the HTTP request while maintaining complete type safety.
Overview
While Chain is simple and convenient, Thunder lets you:
- Customize the fetch implementation
- Add request interceptors
- Handle retries and timeouts
- Implement custom caching
- Control request/response transformation
Creating Thunder
import { Thunder } from './zeus';
const thunder = Thunder(async (query, variables) => {
const response = await fetch('https://api.com/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
if (json.errors) {
throw new Error(json.errors[0].message);
}
return json.data;
});
// Use it like Chain
const result = await thunder('query')({
user: [{ id: '123' }, { name: true, email: true }],
});Authentication
Bearer Token
const thunder = Thunder(async (query, variables) => {
const response = await fetch('https://api.com/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
body: JSON.stringify({ query, variables }),
});
return (await response.json()).data;
});Dynamic Authentication
let authToken = '';
export function setAuthToken(token: string) {
authToken = token;
}
const thunder = Thunder(async (query, variables) => {
const response = await fetch('https://api.com/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken ? `Bearer ${authToken}` : '',
},
body: JSON.stringify({ query, variables }),
});
return (await response.json()).data;
});Token Refresh
const thunder = Thunder(async (query, variables) => {
const makeRequest = async (token: string) => {
const response = await fetch('https://api.com/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query, variables }),
});
return response;
};
let response = await makeRequest(currentToken);
// Refresh token on 401
if (response.status === 401) {
const newToken = await refreshAuthToken();
response = await makeRequest(newToken);
}
const json = await response.json();
return json.data;
});Retry Logic
Exponential Backoff
const thunder = Thunder(async (query, variables) => {
const maxRetries = 3;
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch('https://api.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
if (response.ok) {
return (await response.json()).data;
}
// Don't retry client errors
if (response.status >= 400 && response.status < 500) {
throw new Error(`Client error: ${response.status}`);
}
lastError = new Error(`Server error: ${response.status}`);
} catch (error) {
lastError = error as Error;
}
// Exponential backoff: 1s, 2s, 4s
if (attempt < maxRetries - 1) {
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
}
throw lastError || new Error('Request failed after retries');
});Retry with Jitter
const thunder = Thunder(async (query, variables) => {
const maxRetries = 3;
const baseDelay = 1000;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch('https://api.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
if (response.ok) {
return (await response.json()).data;
}
if (response.status < 500) {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
if (attempt === maxRetries - 1) throw error;
// Exponential backoff with jitter
const delay = baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * delay * 0.1;
await new Promise((resolve) => setTimeout(resolve, delay + jitter));
}
}
throw new Error('Max retries exceeded');
});Timeout Handling
const thunder = Thunder(async (query, variables) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
try {
const response = await fetch('https://api.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
signal: controller.signal,
});
clearTimeout(timeout);
return (await response.json()).data;
} catch (error) {
clearTimeout(timeout);
if ((error as Error).name === 'AbortError') {
throw new Error('Request timeout after 10 seconds');
}
throw error;
}
});Request Logging
const thunder = Thunder(async (query, variables) => {
const startTime = Date.now();
console.log('GraphQL Request:', {
query: query.substring(0, 100),
variables,
timestamp: new Date().toISOString(),
});
try {
const response = await fetch('https://api.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
const json = await response.json();
const duration = Date.now() - startTime;
console.log('GraphQL Response:', {
status: response.status,
duration: `${duration}ms`,
hasErrors: !!json.errors,
});
return json.data;
} catch (error) {
const duration = Date.now() - startTime;
console.error('GraphQL Error:', {
duration: `${duration}ms`,
error: (error as Error).message,
});
throw error;
}
});Error Handling
Detailed Error Handling
class GraphQLError extends Error {
constructor(message: string, public statusCode?: number, public graphQLErrors?: any[]) {
super(message);
this.name = 'GraphQLError';
}
}
const thunder = Thunder(async (query, variables) => {
const response = await fetch('https://api.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
const json = await response.json();
if (json.errors) {
throw new GraphQLError('GraphQL errors occurred', response.status, json.errors);
}
if (!response.ok) {
throw new GraphQLError(`HTTP ${response.status}`, response.status);
}
return json.data;
});
// Usage
try {
const result = await thunder('query')({
user: [{ id: '123' }, { name: true }],
});
} catch (error) {
if (error instanceof GraphQLError) {
console.error('Status:', error.statusCode);
console.error('GraphQL Errors:', error.graphQLErrors);
}
}Custom Headers Per Request
type ThunderContext = {
headers?: Record<string, string>;
};
const thunder = Thunder(async (query, variables, context?: ThunderContext) => {
const response = await fetch('https://api.com/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...context?.headers,
},
body: JSON.stringify({ query, variables }),
});
return (await response.json()).data;
});
// Usage with custom headers
const result = await thunder('query', {
headers: { 'X-Request-ID': 'abc123' },
})({
user: [{ id: '123' }, { name: true }],
});Request Caching
const cache = new Map<string, { data: any; timestamp: number }>();
const CACHE_TTL = 60000; // 1 minute
const thunder = Thunder(async (query, variables) => {
const cacheKey = JSON.stringify({ query, variables });
const cached = cache.get(cacheKey);
// Return cached data if valid
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
console.log('Returning cached response');
return cached.data;
}
// Fetch new data
const response = await fetch('https://api.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
const data = (await response.json()).data;
// Cache the result
cache.set(cacheKey, { data, timestamp: Date.now() });
return data;
});File Upload
const thunder = Thunder(async (query, variables) => {
// Check if variables contain File objects
const hasFiles = Object.values(variables || {}).some((value) => value instanceof File || value instanceof Blob);
if (hasFiles) {
// Use multipart form data for file uploads
const formData = new FormData();
formData.append('operations', JSON.stringify({ query, variables: {} }));
const map: Record<string, string[]> = {};
let fileIndex = 0;
Object.entries(variables || {}).forEach(([key, value]) => {
if (value instanceof File || value instanceof Blob) {
map[fileIndex] = [`variables.${key}`];
formData.append(fileIndex.toString(), value);
fileIndex++;
}
});
formData.append('map', JSON.stringify(map));
const response = await fetch('https://api.com/graphql', {
method: 'POST',
body: formData,
});
return (await response.json()).data;
}
// Regular JSON request
const response = await fetch('https://api.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
return (await response.json()).data;
});Using with Node.js
import fetch from 'node-fetch';
import { Thunder } from './zeus';
const thunder = Thunder(async (query, variables) => {
const response = await fetch('https://api.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
return (await response.json()).data;
});Benefits Over Chain
Full Control
Thunder gives you complete control over the request lifecycle:
const thunder = Thunder(async (query, variables) => {
// Before request
await beforeRequestHook(query, variables);
// Custom fetch logic
const data = await customFetch(query, variables);
// After request
await afterRequestHook(data);
return data;
});Integration with Existing Code
Thunder integrates easily with existing fetch configurations:
// Use your existing fetch setup
import { authenticatedFetch } from './lib/fetch';
const thunder = Thunder(async (query, variables) => {
const response = await authenticatedFetch('https://api.com/graphql', {
method: 'POST',
body: JSON.stringify({ query, variables }),
});
return (await response.json()).data;
});Testing
Mock Thunder for testing:
// In tests
const mockThunder = Thunder(async (query, variables) => {
return mockData; // Return mock data based on query
});
// Production
const thunder = Thunder(async (query, variables) => {
// Real implementation
});Chain vs Thunder
| Feature | Chain | Thunder |
|---|---|---|
| Simplicity | ✅ Very simple | ⚠️ More complex |
| Customization | ❌ Limited | ✅ Full control |
| Fetch Control | ❌ No | ✅ Yes |
| Built-in Retries | ❌ No | ✅ Custom |
| Request Logging | ❌ No | ✅ Custom |
| File Uploads | ❌ No | ✅ Custom |
| Best For | Quick prototypes | Production apps |
Next Steps
- Generated Types - Understanding the generated code
- Selectors - Reusable field selections
- Type Inference - Deep dive into types
Last updated on