A lean, interceptor-driven HTTP client for Dart — built on dart:io with zero third-party dependencies.
Falkon gives you typed results, a clean interceptor chain, multipart uploads with progress tracking, and a sealed Result<T> that keeps exceptions out of your business logic.
- Sealed
Result<T>—SuccessandFailureare pattern-matchable; errors never escape as uncaught exceptions - Interceptor chain — request, response, and error hooks; short-circuit or recover at any stage
- Bundled interceptors —
BearerTokenInterceptor,LoggingInterceptor,RetryInterceptorready to use - Multipart uploads — stream files from disk or bytes with real-time progress callbacks
- File downloads — pipe directly to disk; no full-body buffering
- Per-request overrides — base URL, headers, timeout, and interceptor bypass via
RequestOptions - Fluent config builder — readable, immutable
FalkonClientConfig - Zero dependencies —
dart:io+dart:convertonly
dependencies:
falkon: ^0.1.0dart pub getimport 'package:falkon/falkon.dart';
final client = FalkonClient(
FalkonClientConfig.builder('https://api.example.com')
.timeout(const Duration(seconds: 15))
.addInterceptor(LoggingInterceptor())
.addInterceptor(BearerTokenInterceptor(() => authService.token))
.maxRetries(2)
.build(),
);
// GET
final result = await client.get('/posts/1', parser: Post.fromJson);
result.fold(
onSuccess: (post) => print(post.title),
onFailure: (err) => print('Error: $err'),
);Any class with a fromJson factory works:
class Post {
final int id;
final String title;
final String body;
const Post({required this.id, required this.title, required this.body});
factory Post.fromJson(Map<String, dynamic> json) => Post(
id: json['id'] as int,
title: json['title'] as String,
body: json['body'] as String,
);
}final post = await client.get ('/posts/1', parser: Post.fromJson);
final created = await client.post ('/posts', body: data, parser: Post.fromJson);
final updated = await client.put ('/posts/1', body: data, parser: Post.fromJson);
final patched = await client.patch ('/posts/1', body: data, parser: Post.fromJson);
final deleted = await client.delete('/posts/1');final result = await client.get(
'/search',
query: {'q': 'flutter', 'page': '2'},
parser: SearchResult.fromJson,
);// Pattern match (Dart 3+)
switch (result) {
case Success(:final data, :final statusCode):
print('$statusCode → ${data.title}');
case Failure(:final error):
print('Failed: $error');
}
// Functional helpers
final title = result
.map((post) => post.title.toUpperCase())
.fold(onSuccess: (t) => t, onFailure: (_) => 'unknown');
// Side-effects
result
.onSuccess((post) => cache.store(post))
.onFailure((err) => logger.error(err));| Type | When |
|---|---|
HttpException |
Non-2xx response; carries statusCode and responseBody |
ParseException |
JSON decode or fromJson threw |
ConnectionException |
DNS failure, socket error, no network |
TimeoutException |
Request exceeded its deadline |
UnknownNetworkException |
Everything else |
if (result.isFailure) {
switch (result.error) {
case HttpException(:final statusCode) when statusCode == 401:
await authService.refresh();
case TimeoutException():
showRetryDialog();
default:
logError(result.error);
}
}// Inject a bearer token on every request
BearerTokenInterceptor(() => storage.read('access_token'))
// Pretty-print requests and responses
LoggingInterceptor()
// Retry on connection/timeout failures with exponential back-off
RetryInterceptor(maxAttempts: 3)class AuthRefreshInterceptor extends Interceptor {
final AuthService _auth;
AuthRefreshInterceptor(this._auth);
@override
void onRequest(RequestContext ctx, RequestHandler handler) {
handler.next(
ctx.copyWith(headers: {
...ctx.headers,
'Authorization': 'Bearer ${_auth.accessToken}',
}),
);
}
@override
void onError(NetworkException error, ErrorHandler handler) async {
if (error is HttpException && error.statusCode == 401) {
await _auth.refresh();
// Signal caller to retry — or resolve with a cached response
}
handler.next(error);
}
}// At build time (preferred)
FalkonClientConfig.builder(baseUrl)
.addInterceptor(LoggingInterceptor())
.addInterceptor(AuthRefreshInterceptor(authService))
.build();
// At runtime
client.addInterceptor(AnalyticsInterceptor());final result = await client.upload<UploadResponse>(
'/media/upload',
files: [
UploadFile.fromFile(
fieldName: 'avatar',
file: File('/path/to/photo.jpg'),
contentType: 'image/jpeg',
),
UploadFile.fromBytes(
fieldName: 'thumbnail',
bytes: thumbnailBytes,
filename: 'thumb.png',
contentType: 'image/png',
),
],
fields: {'userId': '42', 'album': 'profile'},
parser: UploadResponse.fromJson,
onProgress: (sent, total) {
print('${(sent / total * 100).toStringAsFixed(1)}%');
},
);final result = await client.download(
'https://files.example.com/report.pdf',
savePath: '/storage/emulated/0/Download/report.pdf',
onProgress: (received, total) => updateProgressBar(received / total),
);
result.fold(
onSuccess: (file) => openFile(file.path),
onFailure: (err) => showError(err.message),
);final result = await client.get(
'/internal/status',
options: RequestOptions(
baseUrl: 'https://internal.example.com',
headers: {'X-Service-Key': secrets.serviceKey},
timeout: const Duration(seconds: 5),
skipInterceptors: true,
),
);class PostRepository extends NetworkRepository {
final NetworkClient _client;
const PostRepository(this._client);
Future<Result<Post>> getPost(int id) => _client.get('/posts/$id', parser: Post.fromJson);
Future<Result<Post>> createPost(Post p) => _client.post('/posts', body: p.toJson(), parser: Post.fromJson);
Future<Result<List<Post>>> listPosts() => _client.get('/posts', parser: (json) => /* ... */);
}class PostService extends NetworkService {
PostService(super.client);
Future<Result<Post>> getValidated(int id) async {
return client
.get('/posts/$id', parser: Post.fromJson)
.then((r) => r.map(_validate));
}
Post _validate(Post post) {
if (post.title.isEmpty) throw const FormatException('Empty title');
return post;
}
}Because everything is coded to the NetworkClient interface, swapping in a fake is trivial:
class MockNetworkClient implements NetworkClient {
final Map<String, dynamic> _responses = {};
void stub(String path, Map<String, dynamic> json) =>
_responses[path] = json;
@override
Future<Result<T>> get<T>(String path, {
Map<String, dynamic>? query,
JsonParser<T>? parser,
RequestOptions? options,
}) async {
final data = _responses[path];
if (data == null) return Failure(const HttpException(statusCode: 404, message: 'Not stubbed'));
return Success(parser != null ? parser(data) : data as T);
}
// ... other methods
}FalkonClientConfig.builder('https://api.example.com')
.headers({'Content-Type': 'application/json', 'Accept': 'application/json'})
.timeout(const Duration(seconds: 30))
.maxRetries(3)
.addInterceptor(LoggingInterceptor())
.addInterceptor(BearerTokenInterceptor(() => token))
.parseErrorBodies() // forward non-2xx JSON bodies to your parser
.build();| Option | Default | Description |
|---|---|---|
baseUrl |
required | Scheme + host prepended to every relative path |
headers |
{'Content-Type': 'application/json; charset=utf-8'} |
Default headers merged with every request |
timeout |
30s |
Combined connection + response deadline |
maxRetries |
0 |
Retry attempts for ConnectionException / TimeoutException |
parseErrorBodies |
false |
Pass non-2xx responses through the JSON parser |
| SDK | Version |
|---|---|
| Dart | >= 3.0.0 |
| Flutter | >= 3.10.0 (if used in a Flutter project) |
No third-party dependencies. Uses only dart:io and dart:convert.
Contributions are welcome. Please open an issue before submitting a pull request for significant changes.
- Fork the repository
- Create a feature branch:
git checkout -b feat/my-feature - Commit with conventional commits:
feat:,fix:,docs: - Open a pull request against
main
Run the self-contained test suite:
dart test.dartMIT — see LICENSE for details.