dio_bolt 0.0.1
dio_bolt: ^0.0.1 copied to clipboard
A lightweight production networking layer built on top of Dio that removes boilerplate while keeping Dio fully accessible.
Dio Bolt
A lightweight, production-focused Dart and Flutter HTTP networking layer built on top of Dio.
Dio Bolt provides a clean API for REST API requests, standardized response handling, automatic access-token refresh, configurable HTTP retries with exponential backoff and jitter, structured API logging, multipart file uploads, and protected file downloads—while keeping the underlying Dio instance fully accessible.
Features #
- Normalized Response Envelope (
DioBoltResponse): Standardizes expected HTTP responses (2xx, 4xx, 5xx), transport errors, timeouts, and cancellations into a consistentDioBoltResponsewithout requiring repetitivetry/catchboilerplate for expected network outcomes. - Complete Payload Preservation: Server response bodies are never stripped or forced into an assumed schema. Access the raw response payload directly in
response.responseData. - Centralized Authentication & Token Refresh: Automatic
Authorizationheader injection with single-flight token refresh coordination, retry-once loop protection, and token persistence hooks. - Resilient Network Retries: Configurable exponential backoff, bounded jitter, server
Retry-Afterheader parsing, status/error eligibility checks, and request body replayability safeguards. - Safe Multipart Replayability: Path- and byte-backed
DioBoltFileuploads can be reconstructed for retries and authentication refresh, while one-shot streams and genericFormDataare never automatically replayed. - Protected Two-Stage Downloads: Downloads stream into temporary
.tmpfiles with automatic parent directory creation, pre-flight collision checks, cleanup on failure/cancellation, and safe destination replacement. - Production Console Logger: Clean, ANSI-colored request/response logs with automatic sensitive-key redaction (
[REDACTED]), binary payload summaries, and isolated error handling. - Unrestricted Dio Access: Direct access to the underlying
api.dioinstance at all times. - Pure Dart Core: Zero UI dependencies, zero state-management lock-in, and zero code generation requirements.
Why Dio Bolt? #
While Dio is a powerful, flexible HTTP client for Dart and Flutter, building production applications often requires writing repetitive glue code around:
- Wrapping every request in
try/catchblocks to catchDioExceptionon standard 4xx/5xx responses. - Managing concurrent 401 token refresh race conditions without duplicate refresh calls or infinite loops.
- Writing exponential backoff and jitter retry loops that avoid retrying consumed multipart streams.
- Handling download temporary files and directory creation safely.
- Scrubbing authorization tokens and passwords from console logs.
Dio Bolt solves these challenges as a unified, cohesive networking layer:
┌────────────────────────────────────────────────────────┐
│ Your Application │
├────────────────────────────────────────────────────────┤
│ Dio Bolt │
│ (Auth Refresh • Retry & Jitter • Safe Logs • Transfer)│
├────────────────────────────────────────────────────────┤
│ Dio │
│ (Interceptors • Adapters • BaseOptions • Cache) │
├────────────────────────────────────────────────────────┤
│ HTTP / REST │
└────────────────────────────────────────────────────────┘
Installation #
Add dio_bolt to your pubspec.yaml:
dependencies:
dio_bolt: ^0.0.1
Or install via terminal:
dart pub add dio_bolt
Note:
dio_boltre-exports common Dio types (CancelToken,Options,FormData,MultipartFile,DioException,Response), so you do not need to installdioseparately for standard usage.
Quick Start #
import 'package:dio_bolt/dio_bolt.dart';
void main() async {
// 1. Initialize client
final api = DioBolt(
options: BaseOptions(baseUrl: 'https://jsonplaceholder.typicode.com'),
enableLogging: true,
);
// 2. Make a request (expected network outcomes resolve to DioBoltResponse)
final response = await api.get('/posts/1');
// 3. Inspect the normalized envelope
if (response.isSuccess) {
print('HTTP ${response.statusCode}: ${response.responseData}');
} else {
print('Request failed (${response.statusCode}): ${response.message}');
}
}
Response Handling (DioBoltResponse) #
All request methods in Dio Bolt resolve to a non-generic DioBoltResponse.
class DioBoltResponse {
final bool isSuccess; // true if HTTP statusCode is in the 200..299 range
final int statusCode; // HTTP status code (e.g. 200, 404, 500) or 0 for transport/cancellation
final dynamic responseData; // Complete raw body from the server (Map, List, String, etc.)
final String? message; // Status message or error description
}
Schema-Agnostic Response Data #
Dio Bolt does not assume your API wraps responses in {"data": ...} or {"success": true}. The complete, unmodified server response is stored in responseData:
final response = await api.get('/users/42');
if (response.isSuccess) {
final Map<String, dynamic> body = response.responseData;
final user = User.fromJson(body);
print('Loaded user: ${user.name}');
} else if (response.statusCode == 404) {
print('User not found: ${response.message}');
} else if (response.statusCode == 0) {
print('Network / connection failure: ${response.message}');
}
HTTP Requests #
Dio Bolt provides clean methods for standard REST operations: get, post, put, patch, and delete.
GET Request #
final response = await api.get(
'/products',
queryParameters: {'category': 'electronics', 'page': 1},
headers: {'X-Custom-Header': 'value'},
);
POST Request #
final response = await api.post(
'/products',
data: {
'title': 'Mechanical Keyboard',
'price': 99.99,
},
);
PUT & PATCH Requests #
// Replace resource
final putResponse = await api.put(
'/products/101',
data: {'title': 'Updated Title', 'price': 89.99},
);
// Partially update resource
final patchResponse = await api.patch(
'/products/101',
data: {'price': 79.99},
);
DELETE Request #
final deleteResponse = await api.delete('/products/101');
Error Handling #
Dio Bolt normalizes expected HTTP errors and transport failures into DioBoltResponse:
| Scenario | isSuccess |
statusCode |
responseData |
message |
|---|---|---|---|---|
| HTTP 2xx (Success) | true |
200–299 |
Server payload | Status text (e.g. 'OK') |
| HTTP 4xx / 5xx (Server Error) | false |
400–599 |
Complete error payload | Server message or default status text |
| Timeout (Connection/Send/Receive) | false |
0 |
null |
Detailed timeout description |
| Connection Failure / Offline | false |
0 |
null |
'Unable to connect to the server.' |
| Request Cancelled | false |
0 |
null |
'Request was cancelled.' |
Because expected HTTP and transport failures resolve to DioBoltResponse, callers do not require repetitive try/catch blocks for routine network outcomes:
final response = await api.get('/dashboard');
if (response.isSuccess) {
renderDashboard(response.responseData);
} else {
showErrorSnackbar(response.message ?? 'An error occurred.');
}
Authentication & Token Refresh #
Dio Bolt includes built-in single-flight token refresh coordination that prevents concurrent 401 storms and race conditions.
final api = DioBolt(
auth: DioBoltAuth(
// 1. Resolve current access token
getAccessToken: () async => await secureStorage.read(key: 'jwt_token'),
// 2. Perform refresh call using the isolated refresh client
refreshToken: (refreshClient) async {
final refreshToken = await secureStorage.read(key: 'refresh_token');
final res = await refreshClient.post(
'https://api.example.com/auth/refresh',
data: {'refreshToken': refreshToken},
);
return res.data['accessToken'];
},
// 3. Hook to persist the refreshed token
onTokenRefreshed: (newToken) async {
await secureStorage.write(key: 'jwt_token', value: newToken);
},
// 4. Hook invoked when refresh fails permanently (e.g. refresh token expired)
onRefreshFailed: (originalResponse) async {
await secureStorage.deleteAll();
navigatorKey.currentState?.pushReplacementNamed('/login');
},
// Optional customizations:
headerKey: 'Authorization', // Default: 'Authorization'
tokenHeaderBuilder: (token) => 'Bearer $token', // Default: 'Bearer $token'
),
);
Authentication Features #
- Single-Flight Coordination: When multiple concurrent requests encounter a
401, exactly one refresh request is executed. All other pending requests await that single result before retrying. - Retry-Once Protection: Retried requests are marked internally to prevent infinite refresh loops.
- Caller Header Preservation: If a caller explicitly supplies a custom authorization header on a specific request, it is preserved without being overwritten.
- 403 Forbidden Behavior: HTTP 403 responses do not trigger a token refresh attempt.
- Cancellation Awareness: Cancelling a request while waiting for a token refresh unblocks immediately.
- Replayability Protection: Only replayable requests (e.g., standard bodies or path-/byte-backed uploads) participate in auth retry; non-replayable streams return the original 401 response.
- Bypass Authentication: Use
Options().copyWithSkipAuth()to bypass token injection and 401 refresh on public endpoints:
final loginResponse = await api.post(
'/auth/login',
data: {'username': 'user', 'password': 'secret'},
options: Options().copyWithSkipAuth(),
);
Network Retry & Resilience #
Dio Bolt features an automated network retry interceptor that respects idempotency, exponential backoff, jitter, and HTTP 429 Retry-After headers.
final api = DioBolt(
retryConfig: DioBoltRetryConfig(
maxRetries: 3, // Max retry attempts
initialDelay: const Duration(milliseconds: 500), // Base backoff delay
maxDelay: const Duration(seconds: 5), // Maximum delay cap
backoffMultiplier: 2.0, // Exponential multiplier
useJitter: true, // Adds random 0%-20% variance
respectRetryAfter: true, // Parses server 429 Retry-After headers
clampRetryAfterToMaxDelay: true, // Clamps Retry-After to maxDelay
retryableStatusCodes: {408, 429, 500, 502, 503, 504},
retryableMethods: {'GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'}, // POST/PATCH excluded by default
),
);
Delay Calculation Precedence #
- Custom
delayCalculator(if provided) takes full precedence over default computation. Retry-AfterHeader: On HTTP 429, parses delta-seconds or HTTP-date (clamped tomaxDelayif configured).- Exponential Backoff: Computes
initialDelay * (backoffMultiplier ^ (attempt - 1)), clamped tomaxDelay. - Jitter: If
useJitter: true, adds a random 0%–20% variance (capped atmaxDelay) to avoid synchronized thundering herds.
Safe Method Idempotency & Per-Request Opt-In #
By default, non-idempotent methods (POST and PATCH) are not retried. You can explicitly opt-in to retries for specific requests using copyWithRetry:
final response = await api.post(
'/idempotent-order',
data: {'orderId': 'ORD-1234'},
options: Options().copyWithRetry(enabled: true),
);
Request Replayability Rules #
The retry policy verifies request body replayability before scheduling retries:
- In-memory bodies (
Map,List,String,num,Uint8List) $\rightarrow$ Replayable - Path-backed / Byte-backed uploads (
DioBoltFile.fromPath,DioBoltFile.fromBytes) $\rightarrow$ Replayable - One-shot streams (
Stream<List<int>>,DioBoltFile.fromStream, genericFormData) $\rightarrow$ Strictly Non-Replayable (will not be retried)
Production Console Logging #
Enable sanitized, structured logging for debugging:
final api = DioBolt(
enableLogging: true, // Simple one-flag enable
// Or custom configuration:
logConfig: DioBoltLogConfig(
enabled: true,
useColors: true, // ANSI terminal colors
logHeaders: true,
logQueryParameters: true,
logRequestBody: true,
logResponseBody: true,
sensitiveKeys: { // Keys masked with [REDACTED]
'authorization',
'password',
'token',
'secret',
'api_key',
},
),
);
Example Console Output #
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🚀 DIO BOLT • REQUEST [#1]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
METHOD : GET
URL : https://api.example.com/v1/profile
PATH : /v1/profile
QUERY : none
HEADERS : {
"Authorization": "[REDACTED]"
}
PAYLOAD : none
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ DIO BOLT • RESPONSE [#1]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
METHOD : GET
URL : https://api.example.com/v1/profile
STATUS : 200 OK
DURATION : 142 ms
RESPONSE DATA :
{
"id": 101,
"username": "developer",
"email": "dev@example.com"
}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Safety: Logging handles formatting and printing errors internally, ensuring logger issues do not interfere with normal request dispatching.
File Uploads #
Upload files using DioBoltFile descriptors. Dio Bolt automatically constructs FormData and reconstructs fresh multipart streams on retries or token refreshes for replayable file descriptors (DioBoltFile.fromPath and DioBoltFile.fromBytes). Stream-backed files (DioBoltFile.fromStream) and generic pre-constructed FormData are non-replayable and are safely rejected from automatic retry.
// 1. Single file from path
final response = await api.upload(
'/users/avatar',
file: DioBoltFile.fromPath(
'/path/to/avatar.png',
fieldName: 'avatar',
filename: 'avatar.png',
contentType: 'image/png',
),
data: {'userId': '123'},
onSendProgress: (sent, total) {
print('Upload progress: ${(sent / total * 100).toStringAsFixed(0)}%');
},
);
// 2. Multiple files from bytes and paths
final multiResponse = await api.upload(
'/documents/batch',
files: [
DioBoltFile.fromBytes(
pdfBytes,
fieldName: 'documents[]',
filename: 'report.pdf',
contentType: 'application/pdf',
),
DioBoltFile.fromPath(
'/path/to/notes.txt',
fieldName: 'documents[]',
filename: 'notes.txt',
),
],
data: {'category': 'finance'},
);
DioBoltFile Constructors & Replayability #
| Constructor | Replayability on Retry / 401 | Use Case |
|---|---|---|
DioBoltFile.fromPath(path, ...) |
Yes (Re-reads from disk) | Files stored on local filesystem |
DioBoltFile.fromBytes(bytes, ...) |
Yes (Re-reads byte buffer) | In-memory files, byte buffers |
DioBoltFile.fromStream(stream, length: ..., ...) |
No (One-shot stream) | Live stream feeds |
File Downloads #
Download files with protected two-stage buffering:
final response = await api.download(
'https://example.com/files/archive.zip',
savePath: '/local/storage/downloads/archive.zip',
overwrite: true, // Default: true
onReceiveProgress: (received, total) {
if (total > 0) {
print('Download: ${(received / total * 100).toStringAsFixed(0)}%');
}
},
);
if (response.isSuccess) {
print('Saved to: ${response.responseData}'); // Returns final savePath
}
Two-Stage Download Lifecycle #
- Automatic Directory Creation: Parent directories are created recursively before the download begins.
- Pre-Flight Conflict Checking: If
overwrite: falseand the destination already exists, Dio Bolt returns a conflict response before dispatching a network request. - In-Flight Destination Protection: Existing destination files are never deleted or modified while the download is in progress.
- Temporary File Buffering: Network data is streamed into an adjacent
.tmpfile. - Safe Destination Replacement: Upon 100% completion, the temporary file is moved to
savePath. On platforms where direct rename over an existing file is rejected (e.g. Windows), a two-phase backup swap with rollback protection is performed. - Automatic Cleanup: Temporary
.tmpfiles are cleaned up on HTTP errors, transport failures, cancellations, or retries.
In-Memory Byte Downloads (downloadBytes) #
To download binary payloads directly into memory without writing to disk:
final response = await api.downloadBytes('https://example.com/image.png');
if (response.isSuccess) {
final Uint8List bytes = response.responseData;
print('Downloaded ${bytes.length} bytes into memory');
}
Request Cancellation #
Use Dio's CancelToken across all requests, uploads, and downloads:
final cancelToken = CancelToken();
// Dispatch request
final future = api.download(
'/large-video.mp4',
savePath: '/storage/video.mp4',
cancelToken: cancelToken,
);
// Cancel the operation
cancelToken.cancel('User navigated away');
final response = await future;
print(response.isSuccess); // false
print(response.statusCode); // 0
print(response.message); // 'Request was cancelled.'
Direct Dio Access (Escape Hatch) #
Dio Bolt does not hide Dio. You can access the underlying Dio instance whenever you need direct control:
final rawDio = api.dio;
// Add third-party interceptors, custom transformers, or custom HTTP adapters:
rawDio.interceptors.add(MyCustomInterceptor());
rawDio.options.connectTimeout = const Duration(seconds: 15);
What Dio Bolt Intentionally Does NOT Do #
To maintain architectural clarity and avoid unnecessary coupling, Dio Bolt intentionally does not provide:
- No State Management Lock-in: Compatible with Bloc, Riverpod, Provider, Signals, or plain Dart/Flutter state.
- No UI Dependencies: Pure Dart core that runs across Flutter and standalone Dart applications.
- No Model Codegen / Reflection: Response mapping is handled directly with standard Dart functions (
fromJson). - No Built-in Caching: Dio Bolt does not enforce a client-side cache store. Attach standard Dio caching interceptors to
api.dioif caching is needed.
Testing & Verification #
Dio Bolt includes an automated test suite covering response handling, authentication single-flight coordination, retry backoff algorithms, console logging, cancellation, uploads, and downloads.
Run the test suite and static analyzer:
dart analyze
dart test
dart run example/main.dart
Compatibility #
- Dart SDK:
^3.9.0 - Flutter:
3.35+(aligned with Dart SDK^3.9.0constraint) - Dio:
^5.11.1 - Supported Platforms: Android, iOS, macOS, Windows, Linux, and Server (Dart VM). (Flutter Web is not supported due to native filesystem transfer requirements using
dart:io).
License #
This project is licensed under the MIT License - see the LICENSE file for details.