netanchor 2.0.0 copy "netanchor: ^2.0.0" to clipboard
netanchor: ^2.0.0 copied to clipboard

unlisted

A swappable, SOLID-compliant networking layer for Flutter & Dart. Adapter pattern over Dio with refresh-token, retry, cache, pagination, and DataKey unwrapping. Zero UI coupling.

netanchor

pub version pub points license: MIT

netanchor #

A typed, swappable HTTP client facade for Dart and Flutter.

Apps depend on the facade. The facade depends on a transport adapter. The day you decide to swap Dio for package:http, you write a new adapter and change one line — every repository, interceptor, and test keeps working.


At a glance #

import 'package:netanchor/netanchor.dart';

final netAnchor = NetAnchor.builder()
  .baseUrl('https://api.example.com')
  .build();

final result = await netAnchor.get<User>('/users/me', parser: User.fromJson);

result.when(
  success: (user) => print('Hi, ${user.name}'),
  failure: (f)    => print('Failed: ${f.localizedKey}'),
);

Installation #

dependencies:
  netanchor: ^2.0.0
import 'package:netanchor/netanchor.dart';

Requires Dart >=3.3.0. The runtime package is pure Dart; Flutter apps use the same API without pulling Flutter into the transport layer. Uses Dart 3 sealed classes, switch expressions, and pattern matching — no codegen, no mirrors.

Migrating from 1.x to 2.0 #

Version 2.0 makes retries safe by default and tightens a few contracts that could previously cause duplicate mutations or mutable shared state. Most 1.x apps only need to update the dependency and run their tests. You need the extra steps below if you have a custom retry policy, intentionally retry a mutation, read headers from a request/response, or mutate a headers map.

Why is this a major release? #

The 1.x API allowed behavior that was convenient but unsafe at scale:

Change Why 2.0 changed it Who needs to migrate?
Mutations no longer retry automatically A repeated payment, check-in, or leave request can run twice on the server Apps that intentionally retry POST/PUT/PATCH/DELETE
RetryPolicy receives RetryContext A policy needs the HTTP method and request context to make a safe decision Apps with a custom RetryPolicy
Headers are lowercase and immutable Prevents case-dependent lookups and accidental mutation shared between requests Code that reads or mutates header maps
Automatic cache keys changed Prevents one authenticated user or tenant from seeing another user's cached response Apps with a persistent custom CacheStore
Secrets are redacted from logs Debug logging must not expose passwords or tokens, including nested JSON values No code change required

The five-minute migration #

If you use the built-in retry policy, do not mutate header maps, and do not need mutation retries, the usual migration is simply:

  1. Change the dependency to netanchor: ^2.0.0.
  2. Run dart pub get (or flutter pub get).
  3. Run your tests.
  4. Check every intentional mutation retry and opt in only when the backend supports idempotency.

Your existing GET, HEAD, authentication, parsing, pagination, and standard root-level refresh-token flows continue to use the same public facade.

1. Update the dependency #

dependencies:
  netanchor: ^2.0.0

Then run:

dart pub get

2. Decide explicitly which mutations may be retried #

In 1.x, a configured retry policy could retry every HTTP method. In 2.0, GET and HEAD still use the retry policy automatically, but POST, PUT, PATCH, and DELETE make one attempt unless you explicitly opt in.

For example, this attendance request still compiles without changes, but it is no longer retried automatically. This is the safe choice when the server cannot protect against duplicate check-ins:

await netAnchor.post(
  '/attendance/check-in',
  body: {'employee_id': employeeId},
);

If the endpoint supports idempotency, generate one operation ID in your app, send it as an idempotency key, and explicitly allow retries:

// Create this once for the check-in operation. Keep the same value if the
// request is retried; do not generate a new value inside the retry attempt.
final checkInOperationId = pendingCheckIn.id;

await netAnchor.post(
  '/attendance/check-in',
  headers: {'Idempotency-Key': checkInOperationId},
  body: {'employee_id': employeeId},
  retryMode: RetryMode.always,
);

The server must treat repeated requests with the same idempotency key as one operation. RetryMode.always only gives netanchor permission to retry; it cannot make a non-idempotent backend safe by itself. To disable retry for one read request, pass retryMode: RetryMode.never.

3. Update custom retry policies #

The old policy received only the attempt number and failure:

// netanchor 1.x
@override
Future<RetryDecision> shouldRetry(
  int attempt,
  NetworkFailure failure,
) async {
  // ...
}

In 2.0 it receives a RetryContext, so a policy can also inspect the method, request headers, and whether the failure came from connectivity, transport, or an HTTP response:

// netanchor 2.0
@override
Future<RetryDecision> shouldRetry(RetryContext context) async {
  if (context.attempt >= 3 || !context.failure.isRetriable) {
    return RetryDecision.stop;
  }

  return const RetryDecision(
    retry: true,
    delay: Duration(milliseconds: 300),
  );
}

Replace attempt with context.attempt and failure with context.failure. Existing ExponentialBackoffPolicy users do not need to change the policy configuration.

4. Read normalized headers and do not mutate their maps #

Request and response header keys are now normalized to lowercase and exposed as unmodifiable snapshots. Supplying headers remains case-insensitive, so this is still valid:

headers: {'Authorization': 'Bearer $accessToken'}

When reading headers, use lowercase keys:

// Before
final contentType = response.headers['Content-Type'];

// After
final contentType = response.headers['content-type'];

If an interceptor needs to add or replace a header, return a copied request instead of mutating request.headers:

final updatedRequest = request.copyWith(
  headers: {
    ...request.headers,
    'x-trace-id': traceId,
  },
);

5. Security and refresh responses #

Traffic logging now recursively replaces sensitive header, query, and body values with [REDACTED] by default, including tokens nested inside JSON. No migration is required; custom sensitive keys can be added when configuring the logger.

The default refresh implementation still understands tokens at the JSON root. A wrapped or otherwise custom response can now be handled without replacing the whole refresher:

{
  "data": {
    "access_token": "new-access-token",
    "refresh_token": "new-refresh-token"
  }
}
AuthConfig(
  tokenStorage: storage,
  refreshPath: '/auth/refresh',
  refreshTokenExtractor: (response) {
    final body = response.data as Map<String, dynamic>;
    final data = body['data'] as Map<String, dynamic>;
    return RefreshedTokens(
      accessToken: data['access_token'] as String,
      refreshToken: data['refresh_token'] as String?,
    );
  },
  // ...the rest of your auth configuration
);

A fully custom TokenRefresher remains supported for flows that need more than response parsing.

6. Let 2.0 rebuild automatic cache entries #

Automatic cache keys are now opaque fingerprints and authenticated requests are isolated by access-token identity. Existing 1.x automatic entries will not be reused after upgrading; they can be cleared immediately or left for your cache backend's normal eviction policy.

For tenant-, company-, or locale-specific responses, add a stable scope:

await netAnchor.get<List<Product>>(
  '/products',
  cache: CacheConfig(
    policy: CachePolicy.cacheFirst,
    scope: 'tenant:$tenantId:locale:$languageCode',
  ),
);

If you supply CacheConfig.key, it remains the complete key and bypasses automatic user/scope isolation. Include the relevant user or tenant identity yourself when using that advanced override.

Migration checklist #

  • Update the dependency to ^2.0.0.
  • Migrate custom RetryPolicy.shouldRetry implementations to RetryContext.
  • Add RetryMode.always only to mutations backed by server-side idempotency.
  • Change header lookups to lowercase.
  • Replace direct header-map mutations with copyWith.
  • Clear old automatic cache entries and add scope where responses vary by tenant or locale.
  • Run your unit/integration tests, especially mutation and token-refresh flows.

Why netanchor? #

Every Flutter team I've seen ships the same NetworkHandler over and over: five copies of try { dio.get/post/put/delete } catch (DioException), showToast and Navigator.push calls smuggled into the network layer, a static _isLoggingOut flag because 401s race each other, Dio types leaking into every repository, no retry, no cache, no proper refresh-token queue.

netanchor consolidates those into one principled architecture so you can stop rewriting them every project.

What you get #

  • Adapter pattern — Dio default, MockAdapter shipped, anything else pluggable.
  • Sealed Result<T, NetworkFailure> with eleven failure cases — no nullable bodies, no magic casts, exhaustive switch enforced by the compiler.
  • Single-flight refresh-token queue — concurrent 401s share one refresh call, then transparently retry. No _isLoggingOut flag.
  • Listener pattern — toasts, navigation, and analytics never touch the library. The library never imports package:flutter.
  • Retry with exponential backoff + jitter — and RateLimitFailure honors the Retry-After header (RFC 7231).
  • Pluggable cache with six strategies: cacheFirst, staleWhileRevalidate, cacheElseNetwork, networkOnly, networkFirst (online → fresh, offline → last known), noCache.
  • Pagination — typed Paginated<T> with offset/cursor/custom parsers.
  • DataKey — auto-unwrap { "data": {...} } envelopes; per-request override.
  • First-class progress callbacks for upload and download.
  • CancellationToken, silent: true per request, dev/prod base URLs, head() and download() helpers, ValidationFailure.fromMap, failure.isRetriable, failure.localizedKey.
  • Pure-Dart RFC 7231 HTTP-date parser — works on Flutter Web (no dart:io leakage in the core).

Quick start #

import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:netanchor/netanchor.dart';

final builder = NetAnchor.builder()
  .baseUrls(
    prod: 'https://api.example.com',
    dev:  'https://dev.example.com',
  )
  .useDev(kDebugMode)
  .dataKey(const DataKey('data'))                 // auto-unwrap envelopes
  .listener(MyAppListener())                      // toasts/navigation here
  .retryPolicy(ExponentialBackoffPolicy(maxAttempts: 3))
  .cacheStore(MemoryCacheStore());

// Per-request traffic log (`→ request`, `← response`, `✗ failure`).
// `.logger(...)` sets the *sink*; `.enableTrafficLog()` is the *switch*
// that produces the traffic. Secrets are redacted by default, but keeping
// production logging intentional still limits PII and log volume.
if (kDebugMode) builder.enableTrafficLog();

final netAnchor = builder.build();

final result = await netAnchor.get<User>(
  '/users/me',
  parser: User.fromJson,
);

Automatic failure retries are safe-by-default: GET and HEAD participate in the configured retry policy, while POST, PUT, PATCH, and DELETE do not. Opt a mutation in only when the endpoint is idempotent (preferably with an idempotency key):

await netAnchor.post(
  '/payments',
  headers: {'Idempotency-Key': operationId},
  body: payment,
  retryMode: RetryMode.always,
);

Pass retryMode: RetryMode.never to disable retries for an individual read.

Custom retry policies receive the complete failed-attempt context:

class IdempotencyAwareRetryPolicy extends RetryPolicy {
  const IdempotencyAwareRetryPolicy();

  @override
  Future<RetryDecision> shouldRetry(RetryContext context) async {
    final hasKey = context.request.headers.containsKey('idempotency-key');
    if (context.request.method == HttpMethod.post && !hasKey) {
      return RetryDecision.stop;
    }
    if (context.attempt >= 3 || !context.failure.isRetriable) {
      return RetryDecision.stop;
    }
    return const RetryDecision(
      retry: true,
      delay: Duration(milliseconds: 300),
    );
  }
}

RetryContext.attempt counts policy-visible failures only; transparent token refreshes do not consume the retry budget. RetryCause distinguishes connectivity, transport, and HTTP-response failures.


Handling failures #

Every verb returns Future<NetworkResult<T>>, a sealed type alias for Result<T, NetworkFailure>. There is no try / catch, no nullable body, no boolean success flag.

switch (result) {
  case Success(:final data):
    showProfile(data);

  case Failure(failure: NoInternetFailure()):       showOfflineBanner();
  case Failure(failure: TimeoutFailure()):          showRetryButton();
  case Failure(failure: UnauthorizedFailure()):     /* listener navigates */;
  case Failure(failure: ForbiddenFailure()):        showLockedFeatureUI();
  case Failure(failure: NotFoundFailure()):         showEmptyState();
  case Failure(failure: ValidationFailure(:final fieldErrors)):
    for (final entry in fieldErrors.entries) {
      formKey.currentState?.invalidateField(entry.key, entry.value.first);
    }
  case Failure(failure: RateLimitFailure(:final retryAfter)):
    showRateLimitedBanner(retryAfter);
  case Failure(failure: ServerFailure()):           showServerDownBanner();
  case Failure(failure: ParseFailure()):            crashlytics.report(failure);
  case Failure(failure: CancelledFailure()):        /* user cancelled */;
  case Failure(failure: UnknownFailure()):          showGenericError();
}

Two convenience helpers on every failure:

if (failure.isRetriable) showRetryButton();
final diagnostic = failure.message;   // server message/error when available
final i18nKey = failure.localizedKey;  // "no_internet", "validation_failed", ...

message and localizedKey have deliberately separate roles. message is the best available diagnostic: HTTP failures use the server's message or error value when present, otherwise netanchor supplies a fallback. It may be useful for logs or domain mapping, but it is not a stable localization key. localizedKey depends only on the failure type and is the value intended for translated UI.

Constructing a result yourself #

The verbs hand you a NetworkResult<T> — but you'll also build one yourself inside repositories (wrapping the DTO into an entity), in fake data sources, and in tests. Two equivalent ways:

// 1. The variant classes directly — explicit, spells out both type args.
return Success<User, NetworkFailure>(user);
return Failure<User, NetworkFailure>(const UnauthorizedFailure());

// 2. The factory constructors — no need to repeat `NetworkFailure`,
//    and the type name matches the method's return type.
NetworkResult<User> loadUser() {
  return NetworkResult<User>.success(user);
  // ...or:
  return NetworkResult<User>.failure(const UnauthorizedFailure());
}

Both compile to the exact same object. Pick whichever reads better to you — the factories just save you from writing NetworkFailure on every line, since in netanchor the failure type is always NetworkFailure.

NoteResult.success / Result.failure are constructors, so they only help when building a result. To inspect one, you still pattern-match on the Success / Failure classes (switch, is Success<...>, result.when(...)) — a type alias can't discriminate the variant at a check site.


Authentication & refresh tokens #

AuthConfig bundles every refresh-related setting in one place:

final netAnchor = NetAnchor.builder()
  .baseUrl('https://api.example.com')
  .auth(AuthConfig(
    tokenStorage: MyKeychainTokenStorage(),
    refreshPath: '/auth/refresh',
    accessTokenPrefix: 'Bearer',
    removeAccessTokenBeforeRefresh: true,

    // Customise the refresh request
    refreshBodyBuilder: (refreshToken) => {
      'refresh_token': refreshToken,
      'grant_type': 'refresh_token',
    },
    refreshHeaders: const {'X-Client-Version': '1.0.0'},

    // Override the JSON keys if your backend uses camelCase
    refreshAccessTokenJsonKey: 'accessToken',
    refreshTokenJsonKey: 'refreshToken',

    // Hooks
    onBeforeRefreshRequest: (req) => req.copyWith(
      headers: {...req.headers, 'X-Trace-Id': uuid.v4()},
    ),
    onTokenRefreshed: (tokens) async {
      analytics.track('token_refreshed');
    },
    onRefreshFailed: (failure) async {
      analytics.track('refresh_failed', failure.message);
    },
  ))
  .build();

If the refresh response is wrapped (or uses any non-standard shape), keep the default HTTP refresh flow and customize extraction only:

AuthConfig(
  tokenStorage: storage,
  refreshPath: '/auth/refresh',
  refreshTokenExtractor: (response) {
    final root = response.data as Map<String, dynamic>;
    final data = root['data'] as Map<String, dynamic>;
    return RefreshedTokens(
      accessToken: data['access_token'] as String,
      refreshToken: data['refresh_token'] as String?,
    );
  },
);

The hook receives the full HttpResponse, so it can support arbitrary nesting or header-based tokens. For custom transport or multi-step refresh flows, implementing TokenRefresher remains the lower-level extension point.

Public endpoints can opt out of automatic token attachment and the entire 401 refresh / unauthorized pipeline on a per-request basis:

final result = await netAnchor.post<AuthSession>(
  '/auth/login',
  body: credentials,
  requiresAuth: false,
  parser: AuthSession.fromJson,
);

requiresAuth defaults to true, so existing calls keep their current behavior. When it is false, an explicitly supplied authentication header is still preserved; only netanchor's automatic auth behavior is skipped.

What happens on a 401 #

  1. The library pauses the failing request.
  2. Concurrent 401s share a single in-flight refresh future — no thundering-herd.
  3. tokenStorage.save(...) persists the new tokens.
  4. onTokenRefreshed fires for side-effects (analytics, sync).
  5. The original request retries transparently with a fresh Authorization header.
  6. If refresh itself fails: tokens are cleared, onRefreshFailed fires, listeners get onUnauthorized, the call returns UnauthorizedFailure.
  7. If the server returns 401 again after a successful refresh, the library does not loop — the auth-retry cap is one. Subsequent 401s fall through to onUnauthorized.

Refresh path bypasses interceptors — by design #

Refresh requests go through the HttpAdapter directly, not the interceptor chain (avoids accidental auth-on-auth loops). For dynamic headers on refresh requests use onBeforeRefreshRequest, not a DynamicHeaderInterceptor.

Custom refresh logic #

Need mTLS, device attestation, or anything exotic? Implement TokenRefresher and pass it through AuthConfig.refresher:

class MyRefresher extends TokenRefresher {
  @override
  Future<RefreshedTokens> refresh(String refreshToken) async {
    final tokens = await myCustomFlow(refreshToken);
    return RefreshedTokens(
      accessToken: tokens.access,
      refreshToken: tokens.refresh,
    );
  }
}

NetAnchor.builder()
  .auth(AuthConfig(tokenStorage: storage, refresher: MyRefresher()))
  .build();

Caching #

final netAnchor = NetAnchor.builder()
  .cacheStore(MemoryCacheStore())
  .build();

final products = await netAnchor.get<List<Product>>(
  '/products',
  parser: Product.fromList,
  cache: const CacheConfig(
    policy: CachePolicy.staleWhileRevalidate,
    ttl: Duration(minutes: 5),
  ),
);
Policy Reads cache? Writes cache? Triggers network?
networkOnly (default) no yes (on 2xx) always
cacheFirst yes (always) yes (on 2xx) only on miss
cacheElseNetwork yes (if fresh) yes (on 2xx) on miss or expired
staleWhileRevalidate yes (always) yes (on 2xx, in background) yes (background)
networkFirst only on offline failure (even if expired) yes (on 2xx) always
noCache no no always

networkFirst is the "online → fresh, offline → last known" strategy: it always tries the network first and returns the fresh response when the server is reachable; only a connectivity/timeout failure makes it fall back to the last cached entry (even an expired one). Other failures (4xx/5xx, parse) pass through untouched — stale cache never masks a real error.

final feed = await netAnchor.get<List<Ad>>(
  '/ads',
  parser: Ad.fromList,
  cache: const CacheConfig(policy: CachePolicy.networkFirst, ttl: Duration(days: 7)),
);
// Online: always fresh. Offline: the last successful response, if any.

cache: works on every verb (get, post, put, patch, delete). Automatic keys are SHA-256 fingerprints of a canonical request description; raw URLs, query values, headers, bodies, and tokens never become store keys. Authenticated requests are partitioned by access-token identity, so cached /users/me data cannot cross from one signed-in user to another. Headers from both NetAnchorConfig.defaultHeaders and the individual request also participate in the fingerprint.

Responses that also vary by tenant, company, locale, or another application dimension through dynamic interceptors should provide a stable scope:

cache: CacheConfig(
  policy: CachePolicy.cacheFirst,
  scope: 'tenant:$tenantId:locale:$languageCode',
)

CacheConfig.key is the low-level escape hatch: it replaces the complete automatic key, including authentication and scope isolation. Use it only when the application constructs a safe, fully-partitioned key itself.

Custom cache backend #

CacheStore is a tiny interface — wire it up to Hive, SQLite, Drift, file system, anything:

class HiveCacheStore extends CacheStore {
  HiveCacheStore(this._box);
  final Box<Map> _box;

  @override
  Future<CacheEntry?> read(String key) async {
    final raw = _box.get(key);
    if (raw == null) return null;
    return CacheEntry(
      data: raw['data'],
      savedAt: DateTime.fromMillisecondsSinceEpoch(raw['savedAt'] as int),
    );
  }

  @override
  Future<void> write(String key, CacheEntry entry) =>
      _box.put(key, {
        'data': entry.data,
        'savedAt': entry.savedAt.millisecondsSinceEpoch,
      });

  @override
  Future<void> remove(String key) => _box.delete(key);

  @override
  Future<void> clear() => _box.clear();
}

Pagination #

final result = await netAnchor.getPaginated<User>(
  '/users',
  parser: User.fromJson,
  queryParameters: {'page': 1, 'per_page': 20},
);

result.when(
  success: (page) {
    print('${page.items.length} of ${page.total}; more=${page.hasMore}');
  },
  failure: (f) => print(f),
);

The default OffsetPaginationParser reads items/data/results/ records for the list and page/per_page/total/total_pages/ has_more for the metadata. Bare arrays work too — if your API returns [ {...}, {...} ] directly, the parser treats it as a one-page result with null metadata.

For Stripe-style cursor pagination:

NetAnchor.builder()
  .paginationParser(const CursorPaginationParser())
  .build();

For an exotic shape, implement PaginationParser yourself — three lines:

class JsonApiPaginationParser extends PaginationParser {
  const JsonApiPaginationParser();
  @override
  Paginated<T> parse<T>(Object? data, ResponseParser<T> itemParser) {
    final map = data! as Map;
    return Paginated<T>(
      items: (map['data'] as List).map(itemParser).toList(),
      nextCursor: (map['links'] as Map?)?['next'] as String?,
      raw: data,
    );
  }
}

File upload (with progress) #

await netAnchor.post<UploadResult>(
  '/files',
  body: UploadFile.path(
    path: file.path,
    filename: file.name,
    contentType: 'image/jpeg',
  ),
  parser: UploadResult.fromJson,
  onSendProgress: (sent, total) {
    final pct = total > 0 ? (sent / total * 100).round() : 0;
    setState(() => _progress = pct);
  },
);

For a multipart form with mixed fields and files:

await netAnchor.post<Album>(
  '/albums',
  body: {
    'title': 'My album',
    'cover':  UploadFile.path(path: cover.path, filename: 'cover.jpg'),
    'photos': [
      for (final f in photos) UploadFile.path(path: f.path, filename: f.name),
    ],
  },
  parser: Album.fromJson,
);

Bytes (no file on disk):

await netAnchor.post<UploadResult>(
  '/files',
  body: UploadFile.bytes(
    bytes: pngBytes,
    filename: 'screenshot.png',
    contentType: 'image/png',
  ),
  parser: UploadResult.fromJson,
);

total may be -1 when the size is unknown (chunked encoding). Handle that case in your UI.


Binary downloads #

final result = await netAnchor.download(
  '/files/large.zip',
  onReceiveProgress: (received, total) {
    debugPrint('downloaded $received / $total bytes');
  },
);

result.when(
  success: (bytes) => File('./large.zip').writeAsBytes(bytes),
  failure: (f)     => print(f),
);

download() sets responseType: HttpResponseType.bytes so the body is delivered as raw List<int>, not parsed as JSON or coerced to String.


Cancellation #

final token = CancellationToken();

final future = netAnchor.get<List<Post>>(
  '/feed',
  parser: Post.fromList,
  cancellationToken: token,
);

@override
void dispose() {
  token.cancel('user_left_screen');
  super.dispose();
}

final result = await future;
// → Failure(CancelledFailure(reason: 'user_left_screen'))

The token is library-neutral. Adapters translate it into their native equivalent (Dio's CancelToken for DioAdapter).


Logging #

netanchor ships three loggers behind a NetAnchorLogger interface, so you can pick the one that fits your build flavor. Plug whichever you want into the builder with .logger(...); the same sink also feeds .enableTrafficLog(), which is the per-request → … ← … ✗ … switch.

PrettyNetAnchorLogger renders every request, response, and failure as a bordered, color-coded box. Visually equivalent to package:logger's PrettyPrinter, wired into the netanchor pipeline.

final netAnchor = NetAnchor.builder()
    .baseUrl('https://api.example.com')
    .logger(const PrettyNetAnchorLogger(printTime: true))
    .enableTrafficLog()                  // debug builds only
    .build();

Traffic logs redact sensitive keys recursively in request headers, request bodies, and response bodies in every build mode. Matching is case-insensitive and covers common variants such as Authorization, password, access_token, refreshToken, client_secret, cookies, and API keys.

Add project-specific keys through the builder:

.enableTrafficLog(
  redactor: const SensitiveDataRedactor(
    additionalSensitiveKeys: {'employee_pin'},
  ),
)

Implement LogRedactor when a custom body type needs special traversal. Redaction never mutates the actual request or response data. It reduces secret exposure but does not make arbitrary production traffic logging risk-free; non-secret personal data can still be present.

Output (cyan for 2xx, yellow for 4xx, red for 5xx and transport failures):

┌─ REQUEST ───────────────────────────────────────────────
│ 12:30:40.550
├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
│ at AuthRepository.login (package:myapp/auth/repo.dart:42:5)
│ at AuthCubit.signIn (package:myapp/auth/cubit.dart:18:7)
│ at LoginPage._submit (package:myapp/ui/login_page.dart:55:9)
├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
│ 💡 → POST /api/auth/login
│ body: {
│   "email": "you@example.com"
│ }
└─────────────────────────────────────────────────────────

The caller-stack section filters out framework frames (package:netanchor/, package:dio/, package:flutter/, dart:async, …) and renders the last user-code frames in Dart's standard (package:foo/bar.dart:42:5) format — Android Studio, VS Code, and flutter logs render those as clickable links.

Knobs:

Parameter Default Purpose
colors true Wrap each line with ANSI color codes. Set false for plain CI logs or sinks that show codes as garbage.
printEmojis true Prefix the message line with 💡 / ⚠️ / ⛔.
printTime false Render an HH:mm:ss.SSS row at the top of the box.
lineLength 120 Total visible width of the box.
callerMethodCount 3 User-code frames in the caller section. 0 hides it.
excludeFromCallerTrace netanchor / dio / flutter / dart Path prefixes to drop from caller frames.
errorMethodCount 8 Frames included for error() stack traces.

Android note. Per-line print() means Android's logcat may interleave system rows (D/BufferQueueProducer, SurfaceView, …) between box rows. Open Android Studio's Logcat tab and filter by tag:flutter — the noise disappears and the box stays intact. (We tried batching the whole box into one print(); Android's ~1 KB log cap silently truncated the trailing border.)

Stdout — DevTools-friendly #

StdoutNetAnchorLogger routes through dart:developer.log, which is what Flutter DevTools' Logging panel reads and what crash reporters (Crashlytics, Sentry) pick up automatically:

.logger(const StdoutNetAnchorLogger(name: 'netanchor'))

Silent — production #

SilentNetAnchorLogger drops everything on the floor. Wire it for release builds so request bodies and tokens never end up in production logs:

.logger(cfg.isDebug
    ? const PrettyNetAnchorLogger()
    : const SilentNetAnchorLogger())

Bringing your own #

Implement NetAnchorLogger. The three core methods are required; override the optional log method too if you want titled, leveled output (otherwise the base class routes it through info / warn / error with the title inlined):

class MyLogger extends NetAnchorLogger {
  @override
  void info(String message) => myLogger.i(message);

  @override
  void warn(String message) => myLogger.w(message);

  @override
  void error(String message, [Object? error, StackTrace? stack]) =>
      myLogger.e(message, error, stack);

  // Optional — only override if you want REQUEST / RESPONSE / FAILURE
  // titles on whatever surface you're rendering to.
  @override
  void log({
    required LogSeverity level,
    required String title,
    required String message,
    Object? error,
    StackTrace? stackTrace,
  }) {
    // …
  }
}

Decoupling UI: the listener pattern #

Networking never imports flutter. UI reactions go through NetAnchorListener:

class MyAppListener extends NetAnchorListener {
  @override
  void onUnauthorized(NetworkEvent event) {
    Session.clear();
    AppRouter.go('/auth/login');
  }

  @override
  void onError(NetworkFailure failure, NetworkEvent event) {
    if (event.isSilent) return;
    Toasts.error(I18n.of(failure.localizedKey));
  }

  @override
  void onNoInternet(NetworkEvent event) {
    if (event.isSilent) return;
    Toasts.warn('You are offline'.tr());
  }

  @override
  void onRetry(int attempt, NetworkEvent event) {
    Analytics.track('http_retry', {'attempt': attempt, 'path': event.request.path});
  }
}

Multiple listeners are allowed; events fan out to all of them.

Two events for two failure modes #

onError and onNoInternet are orthogonal:

Failure source onNoInternet onError
ConnectivityChecker returned false (request never sent) ✅ fires ❌ skipped
Adapter threw noInternet mid-flight ❌ skipped ✅ fires with NoInternetFailure

If you want one hook for both, override both methods and dispatch the same UI.

Per-request silence #

For background polling, prefetching, or analytics calls, suppress UI feedback without changing the listener:

await netAnchor.get('/analytics/config', silent: true);

In your listener, check event.isSilent before showing UI.


Testing your repositories #

MockAdapter ships in the public API. No mocktail required:

import 'package:netanchor/netanchor.dart';
import 'package:test/test.dart';

void main() {
  test('login persists tokens', () async {
    final adapter = MockAdapter()
      ..when(
        method: HttpMethod.post,
        path: '/auth/login',
        bodyMatcher: (body) =>
            body is Map && body['email'] == 'a@b.c',
      ).thenReturn(HttpResponse(
        statusCode: 200,
        data: {'accessToken': 'TOKEN', 'user': {'id': 1, 'name': 'Ada'}},
        headers: const {},
      ));

    final netAnchor = NetAnchor.builder()
      .httpAdapter(adapter)
      .baseUrl('https://api.test')
      .build();

    final result = await AuthRepo(netAnchor).login('a@b.c', 'pw');

    expect(result.isSuccess, isTrue);
    expect(adapter.recordedRequests, hasLength(1));
    expect(adapter.lastRequest!.body, {'email': 'a@b.c', 'password': 'pw'});
  });
}

when(...) accepts:

  • method, path, pathContains — basic verb + URL filters.
  • bodyMatcher: (body) => bool — assert on the payload before answering.
  • headerMatcher: (headers) => bool — assert on headers.
  • queryMatcher: (query) => bool — assert on query parameters.

Then chain one of:

  • .thenReturn(response) — same response every time.
  • .thenThrow(exception)HttpAdapterException for transport failures.
  • .thenReturnInOrder([r1, r2, r3]) — cycles each response, sticks on the last. Great for retry tests.
  • .thenRespond([MockReply.success, MockReply.failure, ...]) — mix successes and exceptions.
  • .withDelay(duration) — pre-delay before answering.

For deterministic retry timing use package:fake_async. For deterministic jitter, pass a seeded Random to ExponentialBackoffPolicy.


Swapping the HTTP client #

The HttpAdapter interface is small:

abstract class HttpAdapter {
  Future<HttpResponse> send(HttpRequest request);
  void close({bool force = false});
}

A bare package:http adapter looks like:

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:netanchor/netanchor.dart';

class HttpPackageAdapter implements HttpAdapter {
  final _client = http.Client();

  @override
  Future<HttpResponse> send(HttpRequest request) async {
    final uri = Uri.parse(request.path).replace(
      queryParameters: request.queryParameters
          .map((k, v) => MapEntry(k, '$v')),
    );

    final req = http.Request(request.method.value, uri)
      ..headers.addAll(request.headers);
    if (request.body != null) req.body = jsonEncode(request.body);

    final streamed = await _client.send(req);
    final body = await streamed.stream.bytesToString();

    return HttpResponse(
      statusCode: streamed.statusCode,
      data: body.isEmpty ? null : jsonDecode(body),
      headers: streamed.headers.map((k, v) => MapEntry(k.toLowerCase(), [v])),
    );
  }

  @override
  void close({bool force = false}) => _client.close();
}

NetAnchor.builder()
  .httpAdapter(HttpPackageAdapter())
  .build();

Every interceptor, repository, listener, and test — untouched.


Architecture in one picture #

            ┌──────────────────┐
            │  Your App / VM   │
            └────────┬─────────┘
                     ▼
            ┌──────────────────┐         ┌─────────────────┐
            │     NetAnchor    │ ──────► │  listeners[]    │  ← UI hooks
            │     (facade)     │         └─────────────────┘
            └────────┬─────────┘
   ┌─────────────────┼─────────────────┬─────────────┐
   ▼                 ▼                 ▼             ▼
┌────────┐     ┌────────────┐    ┌──────────┐ ┌────────────────┐
│Inter-  │     │RetryPolicy │    │CacheStore│ │PaginationParser│
│ceptors │     └────────────┘    └──────────┘ └────────────────┘
└───┬────┘
    ▼
┌──────────────┐         ┌──────────────────┐
│ HttpAdapter  │ ──────► │ DioAdapter       │
│  (interface) │         │ MockAdapter      │
└──────────────┘         │ HttpPackageAdapter (yours)
                         └──────────────────┘

A request enters NetAnchor, walks the interceptor chain on the way out, crosses the HttpAdapter boundary, and the response walks the chain in reverse. The pipeline emits side-effects to listeners at every meaningful step. Failures are mapped into one of eleven typed cases; successes are unwrapped via the optional DataKey and parsed by your parser.

See PLAN.md for the architectural deep-dive and roadmap.


Migrating from a hand-rolled NetworkHandler #

Old New
try { dio.get(...) } catch (DioException) await netAnchor.get<T>(...) returns Result<T, NetworkFailure>
showToast inside the handler NetAnchorListener.onError
Modular.to.navigate('/login') NetAnchorListener.onUnauthorized
_isLoggingOut static flag Built-in single-flight refresh queue
Dio types in repository signatures NetAnchor (no Dio types leak)
responseType is ListMappable ? ... cast parser: T Function(Object?)

Contributing #

netanchor is not accepting external contributions at this time. The API is still settling and there's planned work in flight that would conflict with most PRs. Bug reports filed as issues are appreciated; please don't open pull requests yet.

This notice will be removed once the project opens for external contributors — watch the repo for the announcement.

License #

MIT


Author #

Mahmoud Basuony Mahmoud Basuony
Software Engineer

If netanchor saved you a few hours of boilerplate, a ⭐ on the repo is appreciated.
4
likes
0
points
51
downloads

Publisher

verified publisherbasuony.com

Weekly Downloads

A swappable, SOLID-compliant networking layer for Flutter & Dart. Adapter pattern over Dio with refresh-token, retry, cache, pagination, and DataKey unwrapping. Zero UI coupling.

Repository (GitHub)
View/report issues

Topics

#networking #http #dio #rest #api

License

unknown (license)

Dependencies

crypto, dio, http_parser

More

Packages that depend on netanchor