go_http
Reliable HTTP client for Dart — built for control, cancellation and consistency.
go_http is a modern, cross-platform HTTP client for Dart inspired by Python
httpx. It provides fine-grained control over
requests, structured timeouts, per-phase cancellation, pluggable auth and
transport, batch execution, and a powerful interceptor system.
Features
- Cross-platform — works identically on Dart CLI, Flutter Mobile/Desktop, and Web
- Cancellation — every request can be cancelled mid-flight with
CancellationToken - Retry Policy — smart retry with equal-jitter exponential backoff,
Retry-Afterheader respect - Structured timeouts — per-phase (
connect,read,write,pool) viaTimeout - Upload progress —
onSendProgresscallback tracks bytes written (streaming and non-streaming bodies) - Interceptors — request/response/error chain (
LoggingInterceptor,AuthInterceptor) - Auth SPI —
BasicAuth,DigestAuth(RFC 2617/7616, MD5/SHA-256, qop, cnonce),FunctionAuth - Bearer token refresh — auto-refresh on 401 and single retry
- Cookie Store — RFC-matching jar with
MemoryCookieStore(domain/path, multi-valueSet-Cookie) - Headers — case-insensitive multi-value collection, sensitive-value masking in
toString - Content-Encoding — gzip + deflate (with raw fallback), brotli/zstd via
registerBrotli/registerZstd - Body encoding —
json→application/json,Map→application/x-www-form-urlencoded,peekLengthhelper - Multipart —
multipart/form-dataencoder (zero dependencies, streaming) - Proxy — per-URL-pattern mounts (
ProxyMounts,URLPattern),NO_PROXYsupport,SOCKS5 - Certificate pinning — per-host SHA-256 fingerprint validation (
PinnedCertificates) - Top-level API —
get(),post(),put(),delete(),patch(),head(),options()— no client boilerplate - URL & QueryParams — immutable
Url(httpx-stylecopyWith/join), immutableQueryParams - Event hooks — multicast request/response callbacks, hot-swappable at runtime
- Batch executor — bounded-concurrency batch execution with per-result
Result<T> - Result sinks —
JsonlSink,CsvSink(with formula-injection sanitization) - Type-safe decoders — pluggable
Decoder<T>(JsonDecoder,BytesDecoder, custom) - Response model —
text,json(),raiseForStatus(), charset-aware decoding,hasRedirectLocation,history - MockTransport — handler-backed transport for testing
- Exception hierarchy — 30+ typed error classes (
ConnectTimeoutError,ReadTimeoutError,ProxyError,HttpStatusError, …) - Happy Eyeballs — RFC 8305 dual-stack TCP
HappyEyeballDialer, races IPv6→IPv4 with 300ms head start - Status codes —
StatusCodeenum with 33 entries and category predicates - Metrics — timeline events via
MetricsSink/ConsoleMetricsSink - Base URL — relative request URIs resolved against a client-level
baseUrl - Two-level config —
ClientConfig(transport/timeout/TLS) +ExecutorConfig(interceptors/hooks/enrichers) - Progress reporter — per-batch RPS/percentage/ETA via
ProgressReporter - Response enricher SPI — pluggable enrichers returning custom metadata in
ResponseEnrichment.extra - Response filters —
StatusCodeFilter,RegexFilter,Match(OR/AND) for inclusion/exclusion - Log routing —
stderrLoghelper keeps stdout clean for JSONL/result output - BearerAuth — stateless
BearerAuth('token')strategy with deprecated legacy flow - PortSpec & Headers.parse — nmap-style
http:8080,https:443andKey: Valueparser
Installation
dependencies:
go_http: ^0.2.3
dart pub get
Quick Start
GET (top-level API)
import 'package:go_http/go_http.dart';
void main() async {
final response = await get<Uint8List>(
Uri.parse('https://httpbin.org/get'),
);
print('Status: ${response.statusCode}');
print('Body: ${response.text}');
}
No client boilerplate — GoHttpClient is cached internally. Available:
get, post, put, delete, patch, head, options.
import 'package:go_http/go_http.dart';
final client = GoHttpClient();
try {
final response = await client.get<Uint8List>(
Uri.parse('https://httpbin.org/get'),
);
print('Status: ${response.statusCode}');
print('Body: ${response.text}');
} finally {
client.dispose();
}
POST with JSON
import 'package:go_http/go_http.dart';
final client = GoHttpClient();
try {
final response = await client.post<Uint8List>(
Uri.parse('https://httpbin.org/post'),
json: {'name': 'John', 'email': 'john@example.com'},
);
print('Created: ${response.statusCode}');
print(response.json());
} finally {
client.dispose();
}
POST with form-encoded data
final response = await client.post<Uint8List>(
Uri.parse('https://httpbin.org/post'),
data: {'key1': 'value1', 'key2': 'value2'},
);
Core Concepts
buildRequest / send / request
The client exposes a 3-layer API (mirroring httpx):
buildRequest(Request)— merge client config (default headers, cookies, query params, base URL) onto a rawRequestsend<T>(Request, ...)— dispatch a preparedRequestthrough the full pipeline (interceptors, transport, retry, redirect). Does not re-merge.request<T>(Request, ...)=send(buildRequest(req))— convenience for the common case
Escape hatch: mutate a prepared request before sending.
final req = client.buildRequest(Request.get(uri));
req.headers.add('x-custom', '1');
final res = await client.send(req);
Cancellation
import 'dart:async';
final client = GoHttpClient();
final cancelSource = CancellationSource();
Timer(const Duration(seconds: 2), () {
cancelSource.cancel('Taking too long');
});
try {
final response = await client.get<Uint8List>(
Uri.parse('https://httpbin.org/delay/5'),
cancel: cancelSource.token,
);
} on CancellationError catch (e) {
print('Cancelled: ${e.reason}');
} finally {
cancelSource.dispose();
client.dispose();
}
Structured Timeouts
final client = GoHttpClient(
timeout: const Timeout(
connect: Duration(seconds: 10),
read: Duration(seconds: 30),
write: Duration(seconds: 30),
pool: Duration(seconds: 10),
),
);
// Per-request override: disable connect timeout only
final response = await client.get<Uint8List>(
uri,
options: RequestOptions(
timeout: Timeout(connect: null),
),
);
null = disabled for that phase. useClientDefault (the default) = inherit.
Retry Policy
Default: 3 attempts, 300–2000ms equal-jitter backoff. Retries only for idempotent methods (GET, HEAD, OPTIONS) on network errors, timeouts, and 429/503/504.
final client = GoHttpClient(
retryPolicy: DefaultRetryPolicy(
maxAttempts: 5,
baseDelay: const Duration(milliseconds: 500),
maxDelay: const Duration(seconds: 5),
),
metrics: ConsoleMetricsSink(),
);
Interceptors
class TimingInterceptor extends Interceptor {
@override
Future<Request> onRequest(Request request) async {
print('[${request.methodString}] ${request.uri}');
return request;
}
@override
Future<Response> onResponse(Response response) async {
print('=> ${response.statusCode} (${response.elapsed})');
return response;
}
}
final client = GoHttpClient(
interceptors: [TimingInterceptor(), LoggingInterceptor()],
);
Auth
Basic:
final client = GoHttpClient(
interceptors: [AuthInterceptor(auth: BasicAuth('user', 'pass'))],
);
Digest (RFC 2617/7616):
final client = GoHttpClient(
interceptors: [AuthInterceptor(auth: DigestAuth('user', 'pass'))],
);
Bearer token (pre-resolved):
final client = GoHttpClient(
interceptors: [AuthInterceptor(auth: BearerAuth('my-token'))],
);
Bearer token with auto-refresh (legacy, deprecated):
String? token;
final client = GoHttpClient(
interceptors: [
AuthInterceptor(
tokenProvider: () async => token,
tokenRefresher: () async {
token = await fetchNewToken();
return token;
},
),
],
);
Configuration
Separate ClientConfig (transport/TLS/timeout/proxy/cookies) from ExecutorConfig
(interceptors/metrics/hooks/enrichers). Both are optional — every field falls back
to a sensible default.
final client = GoHttpClient(
clientConfig: const ClientConfig(
connectTimeout: Duration(seconds: 5),
sendTimeout: Duration(seconds: 15),
followRedirects: true,
maxRedirects: 10,
trustEnv: true,
),
executorConfig: ExecutorConfig(
interceptors: [TimingInterceptor()],
enrichers: [CustomEnricher()],
),
);
final errors = client.validate(); // List<ValidationError>
Config from JSON / env:
final cfg = ClientConfig.fromJson({
'connectTimeout': 5000,
'maxRedirects': 10,
});
final withEnv = ClientConfig.mergeEnv(cfg); // GO_HTTP_* overrides
Event Hooks
final client = GoHttpClient(
eventHooks: EventHooks(
request: [(req) => print('>> ${req.uri}')],
response: [(res) => print('<< ${res.statusCode}')],
),
);
// Hot-swap at runtime:
client.eventHooks = client.eventHooks.copyWith(
response: [(res) => metrics.record(res)],
);
Proxy
final client = GoHttpClient(
proxyMounts: ProxyMounts({
URLPattern.parse('http://*'): Proxy.parse('http://proxy:8080'),
URLPattern.parse('https://*.internal'): null, // direct
}),
trustEnv: true, // also honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY
);
final client = GoHttpClient(
verify: '/etc/ssl/certs/ca-certificates.crt', // custom CA file
// verify: false, // disable TLS verification (not recommended)
);
Certificate Pinning
final client = GoHttpClient(
clientConfig: const ClientConfig(
pinnedCertificates: PinnedCertificates(
pins: {
'api.example.com': ['8Rw90Ej3T3i3C3oG7gQVo0GxGxLxPxQxRxSxTxUxVxWxXxY='],
},
),
),
);
Fingerprints are SHA-256 of the server's DER-encoded X.509 certificate,
base64-encoded (without the sha256/ prefix). To compute one:
import 'dart:convert' show base64;
import 'package:crypto/crypto.dart' show sha256;
final fp = base64.encode(sha256.convert(cert.der).bytes);
ponytail: Pin check runs when system CA rejects the cert. True MITM-with-forged-CA pinning requires a
PinningDialerwrappingSecureSocketdirectly.
Happy Eyeballs (RFC 8305)
HappyEyeballDialer resolves a hostname to all IPv6 and IPv4 addresses,
then races connections: IPv6 starts immediately (300ms head start), IPv4 begins
after the delay. The first successful connection wins; losers are destroyed.
import 'package:go_http/go_http.dart';
Future<void> main() async {
final dialer = const HappyEyeballDialer();
try {
final socket = await dialer.dial(
'example.com',
80,
timeout: const Duration(seconds: 10),
);
print('Connected to ${socket.remoteAddress.address}');
print('Address family: ${socket.remoteAddress.type}');
socket.destroy();
} catch (e) {
print('Connection failed: $e');
}
}
Configurable delay and DNS timeout:
final dialer = const HappyEyeballDialer(
ipv6Delay: Duration(milliseconds: 500), // default 300ms
dnsTimeout: Duration(seconds: 5), // default 10s
);
ponytail: No early-fallback optimisation (start IPv4 when all IPv6 fail before the delay expires). Add when per-connection latency stats make the 300ms gap visible. Not integrated into
IoTransportyet — theDialerSPI awaits a transport rewrite.
Headers
final headers = Headers({'content-type': 'application/json'});
headers.add('set-cookie', 'session=abc');
headers.add('set-cookie', 'token=xyz');
print(headers['content-type']); // "application/json"
print(headers.getAll('set-cookie')); // ["session=abc", "token=xyz"]
// Sensitive headers are masked in toString:
print(headers); // ... authorization: [secure] ...
URL & QueryParams
final url = Url.parse('HTTPS://EXAMPLE.COM:443/path?a=1');
print(url.scheme); // "https" (lowercased)
print(url.port); // null (443 is default)
final joined = url.join('/sub?b=2');
print(joined); // https://example.com/sub?b=2
final q = QueryParams({'a': '1', 'b': '2'})
.add('b', '3');
print(q.toQueryString()); // a=1&b=2&b=3
Multipart
final mp = Multipart(
fields: [MultipartField('name', 'file.txt')],
files: [MultipartFile.bytes('file', 'photo.jpg', imageBytes)],
);
final response = await client.post<Uint8List>(
uri,
options: RequestOptions(body: mp),
);
// Content-Type and body are auto-encoded.
Content-Encoding
// gzip and deflate are decoded automatically by default.
// Register brotli/zstd when packages are available:
registerBrotli(() => BrotliDecoder());
registerZstd(() => ZstdDecoder());
// Disable decompression per request:
final response = await client.get<Uint8List>(
uri,
options: RequestOptions(autoDecompress: false),
);
Batch Executor
final executor = BatchExecutor(client, concurrency: 10);
final results = await executor.run<Uint8List>(
uris.map((u) => Request.get(u)).toList(),
onProgress: (done, total) => print('$done/$total'),
);
for (final result in results) {
result.when(
ok: (res) => print('OK: ${res.statusCode}'),
fail: (err, req) => print('FAIL: $err'),
);
}
Batch with progress reporter:
final progress = ProgressReporter(total: 1000);
final results = await executor.run(
requests,
onProgress: (done, total) => progress.tick(),
);
// Done: progress.summary → "50.1% 125rps ETA 4s"
Multiple result callbacks:
final results = await executor.run(
requests,
onResult: logResult,
onResults: [writeToSink, updateCounter],
);
Result Sinks
final sink = ResultSink.jsonl('results.jsonl');
for (final result in results) {
sink.write(result);
}
await sink.close();
Writes are two-phase (temp file + atomic rename on close()) — safe for
parallel batch writers. Also available: CsvSink with formula-injection
sanitization.
Response Filters
// Include only 2xx + body matching a pattern
final filter = Match.any([
StatusCodeFilter.between(200, 299),
RegexFilter(RegExp(r'admin')),
]);
if (filter.matches(response)) {
// process
}
Composable via Match.any (OR, short-circuit) and Match.all (AND,
short-circuit on first miss). Ready for inclusion/exclusion in batch
pipelines.
Response Model
final response = await client.get<Uint8List>(uri);
response.text; // body decoded as text (charset-aware)
response.json(); // body decoded as JSON
response.raiseForStatus(); // throw HttpStatusError on 4xx/5xx (chainable)
response.hasRedirectLocation; // true for navigable 3xx with Location
response.history; // redirect chain
response.elapsed; // full round-trip duration
response.charsetEncoding; // from Content-Type
response.encoding = 'windows-1251'; // override charset
response.defaultEncoding = (bytes) => detect(bytes); // auto-detect hook
response.numBytesDownloaded; // body byte count (after decompress)
response.bytes; // body as Stream<List<int>>
response.links['next']?['url']; // parsed Link: header
response.nextRequest; // computed redirect request (when followRedirects:false)
response.httpVersion; // "HTTP/1.1", "HTTP/2.0", etc.
response.reasonPhrase; // alias for statusMessage
MockTransport
final mt = MockTransport((req) async {
return Response(
request: req,
statusCode: 200,
headers: {'content-type': 'text/plain'},
data: Uint8List.fromList(utf8.encode('mock body')),
);
});
final client = GoHttpClient(transport: mt);
final res = await client.get<Uint8List>(uri);
print(mt.callCount); // 1
Error Handling
try {
final response = await client.get<Uint8List>(uri);
} on ConnectTimeoutError catch (e) {
print('Connection timed out after ${e.timeout}');
} on ReadTimeoutError catch (e) {
print('Server did not send data in time');
} on ConnectError catch (e) {
print('DNS or TCP-level failure: ${e.message}');
} on HttpStatusError catch (e) {
print('HTTP ${e.statusCode}: ${e.response.text}');
} on CancellationError catch (e) {
print('Cancelled: ${e.reason}');
} on HttpError catch (e) {
print('Generic HTTP error: $e');
}
HttpStatusError deliberately does not extend RequestError, so
catch (RequestError) never accidentally swallows a 4xx/5xx.
Platform Support
| Platform | Transport | Status |
|---|---|---|
| Dart CLI / Server | IoTransport (dart:io) |
✅ Full support |
| Flutter Mobile/Desktop | IoTransport (dart:io) |
✅ Full support |
| Flutter Web / WASM | WebTransport (package:web + dart:js_interop) |
✅ Full support |
The correct transport is selected automatically via conditional imports.
Stub classes ensure compilation on all platforms with zero configuration.
WASM compilation is supported — WebTransport uses package:web + dart:js_interop instead of dart:html.
Examples
dart run example/simple_get.dart # GET with client
dart run example/top_level_get.dart # GET without client (top-level API)
dart run example/happy_eyeball.dart # RFC 8305 dual-stack TCP dialer
dart run example/cancel_request.dart
dart run example/retry_policy.dart
dart run example/download_progress.dart # download with onProgress
dart run example/upload_progress.dart # upload with onSendProgress
dart run example/post_json.dart
dart run example/batch_config.dart
License
BSD 3-Clause. See LICENSE.
Libraries
- go_http
- go_http - Reliable HTTP client for Dart