Advanced Fetcher with Retry and Timeout
A production-ready HTTP fetcher with advanced error handling, automatic retries, configurable timeouts, and abort functionality. Perfect for real-world applications where network reliability matters.
Implementation Details
Section titled “Implementation Details”This advanced fetcher demonstrates sophisticated state machine patterns for HTTP requests:
- Configurable retry logic - Automatic retries with exponential backoff for network errors, timeouts, and failures
- Request timeout management - Configurable timeout with automatic cleanup
- Abort capability - Cancel in-flight requests with proper cleanup
- Network error detection - Distinguishes between network errors, timeouts, and other failures
- Detailed state tracking - Comprehensive state management for the entire fetch lifecycle
- Live configuration - Change settings and see the machine recreate with new options
The fetcher uses effects and guards to implement complex retry logic while maintaining clean state transitions.
Source Code
Section titled “Source Code”import { createMachine, defineStates, delay, effect, enter, guard, whenEventType, whenState, assignEventApi, setup,} from "matchina";import { autotransition } from "../lib/autotransition";
type Options = RequestInit & { timeout?: number; maxTries?: number; autoretry?: boolean; getData?: (response: Response) => any;};
export function createFetcher( defaultUrl: string, defaultOptions: Options = {}) { const states = defineStates({ Idle: undefined, Fetching: (url = defaultUrl, options = defaultOptions) => ({ url, options, abort: new AbortController(), }), ProcessingResponse: (response: Response) => response, Resolved: (data: any) => data, Error: (error: Error) => error, NetworkError: (error: Error) => error, Aborted: undefined, TimedOut: undefined, Refetching: undefined, }); const canRefetch = { refetch: "Refetching" } as const;
const machine = createMachine( states, { Idle: { fetch: "Fetching", }, Fetching: { fetched: "ProcessingResponse", reject: "Error", abort: "Aborted", timeout: "TimedOut", networkError: "NetworkError", }, ProcessingResponse: { ...canRefetch, resolve: "Resolved", error: "Error", }, Resolved: canRefetch, Error: canRefetch, NetworkError: canRefetch, Aborted: canRefetch, TimedOut: canRefetch, Refetching: { "": "Fetching" }, }, "Idle" ); const fetcher = Object.assign(assignEventApi(machine), { fetch: (url?: string, options?: RequestInit) => { fetcher.fetch(url, options); return fetcher.promise!; }, promise: undefined as undefined | Promise<Response>, done: undefined as undefined | Promise<void>, tries: 0, }); const maxTries = defaultOptions.maxTries ?? Number.POSITIVE_INFINITY; const runFetch = async (url: string, options: RequestInit) => { fetcher.tries = fetcher.tries + 1; const promise = (fetcher.promise = fetch(url, options)); try { const res = await promise; if (fetcher.promise === promise) { fetcher.fetched(res.clone()); } } catch (error) { const { name } = error as Error; if (name === "AbortError") { fetcher.tries--; } else if (name === "TypeError") { fetcher.networkError(error as Error); } else { fetcher.reject(error as Error); } } finally { delete fetcher.promise; } }; const resolveResponseData = (response: Response) => response.json(); setup(fetcher)( enter( whenState("Fetching", (ev) => { (ev as any).promise = runFetch(ev.to.data.url, { ...ev.to.data.options, signal: ev.to.data.abort.signal, }); const { timeout } = ev.to.as("Fetching").data.options; if (timeout) { const timer = setTimeout(fetcher.timeout, timeout); return () => clearTimeout(timer); } }) ), effect( whenState("ProcessingResponse", (ev) => { fetcher.tries = 0; delay(1000).then(() => { resolveResponseData(ev.to.data).then(fetcher.resolve); }); }) ), autotransition(), effect( whenEventType("abort", (ev) => { ev.from.data.abort.abort(); }) ), guard((ev) => (ev.type === "refetch" ? fetcher.tries < maxTries : true)) ); if (defaultOptions.autoretry) { const autoRetryStates = ["NetworkError", "TimedOut", "Error"] as const;
setup(fetcher)( ...autoRetryStates.map((stateName) => effect( whenState(stateName, () => { if (fetcher.tries < maxTries) { const backoff = 1000 * fetcher.tries; const timer = setTimeout(() => { fetcher.refetch(); }, backoff); return () => { clearTimeout(timer); }; } }) ) ) ); } return fetcher;}
// Removed unused type
export type FetcherMachine = ReturnType<typeof createFetcher>;
import { useMemo } from "react";import { useMachine } from "matchina/react";import { createFetcher } from "./machine";
export const useAdvancedFetcher = ( url = "https://httpbin.org/delay/1", options = { method: "GET", maxTries: 5, timeout: 2000, autoretry: true, }) => { const { method, maxTries, timeout, autoretry } = options; const fetcher = useMemo(() => { return createFetcher(url, options); }, [url, method, maxTries, timeout, autoretry]);
useMachine(fetcher);
return fetcher;};
import { useMachine } from "matchina/react";import { MachineActions } from "../lib/MachineActions";import type { FetcherMachine } from "./machine";
interface FetcherAppViewProps { machine: FetcherMachine;}
export function FetcherAppView({ machine }: FetcherAppViewProps) { useMachine(machine); const state = machine.getState(); const { tries } = machine; return ( <div className="space-y-4 p-4 border rounded-lg max-w-full"> <div className="space-y-2"> <h3 className="text-lg font-bold">Advanced Fetcher</h3> <div className="flex items-center gap-2"> <span className="font-medium">State:</span> <span className={state.match({ Idle: () => "text-gray-500", Fetching: () => "text-blue-500", ProcessingResponse: () => "text-blue-300", Resolved: () => "text-green-500", Error: () => "text-red-500", NetworkError: () => "text-red-500", Aborted: () => "text-yellow-500", TimedOut: () => "text-orange-500", Refetching: () => "text-purple-500", })} > {state.key} </span> </div> {tries > 0 && ( <div className="text-sm"> <span className="font-medium">Attempts:</span> {tries} </div> )} </div>
<div> <h4 className="text-sm font-medium mb-2">Actions:</h4> <MachineActions transitions={machine.transitions} state={state.key} send={machine.send} children={undefined} /> </div>
<div> <h4 className="text-sm font-medium mb-2">Response:</h4> <div className="bg-gray-100 dark:bg-gray-800 p-3 rounded text-sm max-w-full overflow-hidden"> {state.match({ Resolved: () => <span className="text-green-600">Success!</span>, Error: (error: Error) => ( <span className="text-red-600">Error: {error?.message}</span> ), NetworkError: (error: Error) => ( <span className="text-red-600"> Network Error: {error?.message} </span> ), _: () => <span className="text-gray-500">n/a</span>, })} </div> </div> </div> );}
export interface FetcherOptions { url: string; timeout: number; maxTries: number; autoretry: boolean;}
interface OptionsFormProps { options: FetcherOptions; onChange: (options: FetcherOptions) => void;}
export function OptionsForm({ options, onChange }: OptionsFormProps) { const handleChange = (updates: Partial<FetcherOptions>) => { onChange({ ...options, ...updates }); };
return ( <div className="p-4 border rounded-lg bg-gray-50 dark:bg-gray-900 mb-4"> <h4 className="text-sm font-medium mb-3">Configuration:</h4> <div className="grid grid-cols-2 gap-4 text-sm"> <div className="space-y-3"> <div> <label className="block font-medium mb-1">URL:</label> <input type="text" value={options.url} onChange={(e) => handleChange({ url: e.target.value })} className="w-full px-2 py-1 border rounded text-xs" /> </div> <div> <label className="block font-medium mb-1">Timeout (ms):</label> <input type="number" value={options.timeout} onChange={(e) => handleChange({ timeout: Number(e.target.value) }) } className="w-full px-2 py-1 border rounded text-xs" /> </div> </div> <div className="space-y-3"> <div> <label className="block font-medium mb-1">Max Tries:</label> <input type="number" value={options.maxTries} onChange={(e) => handleChange({ maxTries: Number(e.target.value) }) } className="w-full px-2 py-1 border rounded text-xs" /> </div> <div> <label className="flex items-center gap-2 font-medium"> <input type="checkbox" checked={options.autoretry} onChange={(e) => handleChange({ autoretry: e.target.checked })} /> Auto-retry </label> </div> </div> </div>{" "} </div> );}
export const defaultOptions: FetcherOptions = { url: "https://httpbin.org/delay/1", timeout: 2000, maxTries: 5, autoretry: true,};
import { useState } from "react";import { useAdvancedFetcher } from "./hooks";import { FetcherAppView } from "./FetcherAppView";import { OptionsForm, defaultOptions, type FetcherOptions,} from "./OptionsForm";
export function FetcherDemo() { const [options, setOptions] = useState<FetcherOptions>(defaultOptions);
const fetcher = useAdvancedFetcher(options.url, { method: "GET", timeout: options.timeout, maxTries: options.maxTries, autoretry: options.autoretry, });
return ( <div> <OptionsForm options={options} onChange={setOptions} /> <FetcherAppView machine={fetcher} /> </div> );}
How It Works
Section titled “How It Works”The advanced fetcher extends the basic fetch pattern with sophisticated error handling:
- Idle - Ready to make a request with configurable options
- Fetching - Request in flight with timeout and abort capability
- ProcessingResponse - Parsing response data with error handling
- Resolved - Request succeeded, displays success state
- Error/NetworkError/TimedOut - Various failure states with retry capability
- Refetching - Automatic retry with exponential backoff
- Aborted - User-cancelled request
The configuration form recreates the machine when options change, demonstrating how to handle dynamic machine configuration in React applications.
Next Steps
Section titled “Next Steps”Now that you’ve seen an advanced fetcher, you might want to explore:
- Promise Fetcher - Basic promise machine pattern
- State Effects Guide - Learn about effects and lifecycle management
- Matchbox Type Inference - Learn about Matchbox type inference