Skip to main content

polly_dart_dio

polly_dart_dio bridges polly_dart's CancellationToken to Dio's native CancelToken. When the pipeline cancels (timeout, hedging winner found, manual cancel), Dio receives the signal and closes the socket through its own HTTP adapter — giving you true socket-level abort with zero boilerplate.

Installation

dependencies:
polly_dart: ^0.0.8
polly_dart_dio: ^0.0.1
dio: ^5.0.0

Quick start

import 'package:dio/dio.dart';
import 'package:polly_dart/polly_dart.dart';
import 'package:polly_dart_dio/polly_dart_dio.dart';

final dio = Dio();

final pipeline = ResiliencePipelineBuilder()
.addTimeout(Duration(seconds: 5))
.addRetry(RetryStrategyOptions(maxRetryAttempts: 3))
.build();

final data = await pipeline.execute((context) async {
final response = await dio.get(
'https://api.example.com/data',
cancelToken: context.cancellationToken.toDioCancelToken(),
);
return response.data;
});

One call — context.cancellationToken.toDioCancelToken() — and Dio handles the rest.

API

toDioCancelToken()

An extension method on CancellationToken:

extension CancellationTokenDioExtension on CancellationToken {
CancelToken toDioCancelToken();
}

Returns a new dio.CancelToken that is cancelled when the polly token is cancelled. Each call returns a distinct token, so you can safely call it multiple times within one pipeline execution (e.g., for separate Dio requests).

final dioToken = context.cancellationToken.toDioCancelToken();

Cancellation propagates via CancellationToken.whenCancelled (a Future<void>). The polly token's state does not change when the Dio token is cancelled directly.

Cancellation behaviour

ScenarioResult
Token not cancelledDio request completes normally
Token cancelled before request startsDio token is already cancelled; Dio throws DioException with type cancel
Token cancelled mid-requestDio token is cancelled; socket closed by Dio's adapter
Dio token cancelled independentlyPolly token is unaffected

Handling DioException

When Dio cancels a request it throws a DioException with type == DioExceptionType.cancel. If your pipeline handles all exceptions by default (retry, fallback, circuit breaker), you may want to exclude cancellations:

final pipeline = ResiliencePipelineBuilder()
.addRetry(RetryStrategyOptions(
shouldHandle: PredicateBuilder()
.handle<DioException>() // only handle DioException...
.handleOutcome((outcome) {
// ...but not cancellations
if (outcome.hasException &&
outcome.exception is DioException &&
(outcome.exception as DioException).type == DioExceptionType.cancel) {
return false;
}
return true;
})
.build(),
))
.build();

Or more simply — check OperationCancelledException if you wrap Dio in a helper that converts cancellations:

// Helper that converts DioException(cancel) → OperationCancelledException
Future<Response<T>> cancellableGet<T>(
Dio dio,
String url,
CancellationToken token,
) async {
try {
return await dio.get<T>(url, cancelToken: token.toDioCancelToken());
} on DioException catch (e) {
if (e.type == DioExceptionType.cancel) {
throw OperationCancelledException('Dio request cancelled by polly_dart pipeline');
}
rethrow;
}
}

Real-world example

import 'package:dio/dio.dart';
import 'package:polly_dart/polly_dart.dart';
import 'package:polly_dart_dio/polly_dart_dio.dart';

class UserService {
final Dio _dio;
final ResiliencePipeline _pipeline;

UserService()
: _dio = Dio(BaseOptions(baseUrl: 'https://api.example.com')),
_pipeline = ResiliencePipelineBuilder()
.addTimeout(Duration(seconds: 8))
.addRetry(RetryStrategyOptions(
maxRetryAttempts: 3,
delay: Duration(milliseconds: 500),
backoffType: DelayBackoffType.exponential,
shouldHandle: PredicateBuilder()
.handle<DioException>()
.handleOutcome((o) =>
o.hasException &&
o.exception is DioException &&
(o.exception as DioException).type != DioExceptionType.cancel)
.build(),
))
.addCircuitBreaker()
.build();

Future<List<User>> getUsers() {
return _pipeline.execute((context) async {
final response = await _dio.get<List<dynamic>>(
'/users',
cancelToken: context.cancellationToken.toDioCancelToken(),
);
return response.data!.map((j) => User.fromJson(j)).toList();
});
}

Future<User> createUser(String name, String email) {
return _pipeline.execute((context) async {
final response = await _dio.post<Map<String, dynamic>>(
'/users',
data: {'name': name, 'email': email},
cancelToken: context.cancellationToken.toDioCancelToken(),
);
return User.fromJson(response.data!);
});
}
}

class User {
final int id;
final String name;
final String email;

User({required this.id, required this.name, required this.email});

factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'],
name: json['name'],
email: json['email'],
);
}

Using with Dio interceptors

toDioCancelToken() works seamlessly alongside Dio interceptors — just pass it in the request options as normal:

_dio.interceptors.add(LogInterceptor(responseBody: true));

final response = await _dio.get(
'/data',
cancelToken: context.cancellationToken.toDioCancelToken(),
options: Options(headers: {'Authorization': 'Bearer $token'}),
);

Multiple requests in one execution

Each call to toDioCancelToken() returns a distinct CancelToken, all linked to the same polly token. This lets you make parallel Dio requests that all cancel together:

await pipeline.execute((context) async {
final token = context.cancellationToken;

// Both are cancelled the moment the pipeline cancels
final results = await Future.wait([
dio.get('/users', cancelToken: token.toDioCancelToken()),
dio.get('/posts', cancelToken: token.toDioCancelToken()),
]);

return results;
});

Using with Retrofit

Retrofit for Dart generates type-safe API clients on top of Dio. Because toDioCancelToken() returns a plain Dio CancelToken, Retrofit understands it natively — no adapter or wrapper needed.

Add a @CancelRequest() parameter to any endpoint you want to be cancellable:

import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';

part 'api_service.g.dart';

(baseUrl: 'https://api.example.com')
abstract class ApiService {
factory ApiService(Dio dio, {String baseUrl}) = _ApiService;

('/users')
Future<List<User>> getUsers({() CancelToken? cancelToken});

('/users')
Future<User> createUser(
() Map<String, dynamic> body, {
() CancelToken? cancelToken,
});
}

Then inside a pipeline execution:

final users = await pipeline.execute((context) async {
return apiService.getUsers(
cancelToken: context.cancellationToken.toDioCancelToken(),
);
});

That's the entire integration. The only one-time cost is adding @CancelRequest() CancelToken? cancelToken to each endpoint and re-running build_runner.

Flutter: cancelling on screen navigation (Cubit)

A common Flutter pattern — user navigates away mid-request and the in-flight call should be aborted. The natural hook is Cubit.close(), which Flutter calls automatically when the BlocProvider disposes.

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:dio/dio.dart';
import 'package:polly_dart/polly_dart.dart';
import 'package:polly_dart_dio/polly_dart_dio.dart';

class UserCubit extends Cubit<UserState> {
final ApiService _apiService;
final ResiliencePipeline _pipeline;
ResilienceContext? _activeContext;

UserCubit(this._apiService, this._pipeline) : super(UserInitial());

Future<void> loadUsers() async {
// Cancel any previous in-flight call before starting a new one.
// Safe to call even if the previous request already finished.
_activeContext?.cancel();
final context = ResilienceContext();
_activeContext = context;

emit(UserLoading());
try {
final users = await _pipeline.execute(
(ctx) => _apiService.getUsers(
cancelToken: ctx.cancellationToken.toDioCancelToken(),
),
context: context,
);
emit(UserLoaded(users));
} on DioException catch (e) {
// Screen is gone — swallow the signal, no stale state emitted.
if (e.type == DioExceptionType.cancel) return;
emit(UserError(e.message ?? 'Request failed'));
} on TimeoutRejectedException {
emit(UserError('Request timed out'));
}
}


Future<void> close() {
_activeContext?.cancel(); // abort any in-flight request
return super.close();
}
}

What happens when the user navigates away

User swaps Screen A → Screen B
└── Flutter disposes Screen A's widget tree
└── BlocProvider calls cubit.close()
└── _activeContext.cancel()
└── CancellationToken.whenCancelled completes
└── toDioCancelToken() listener fires
└── Dio closes the socket
└── DioException.cancel thrown
└── caught silently — no stale emit

Why _activeContext is per-request, not per-cubit

Holding a single context at the cubit level would permanently cancel it after the first navigation. A per-request context means:

  • Rapid taps — previous call cancelled, new one starts cleanly
  • Navigation away — current call cancelled, no stale UserLoaded emitted after dispose
  • Navigation backBlocProvider creates a fresh cubit with a fresh context, no leftover state

Testing

test('Dio token is cancelled when polly pipeline times out', () async {
final pipeline = ResiliencePipelineBuilder()
.addTimeout(Duration(milliseconds: 50))
.build();

CancellationToken? captured;

await expectLater(
pipeline.execute((context) async {
captured = context.cancellationToken;
// Simulate a slow request
await Future.delayed(Duration(milliseconds: 500));
return 'never';
}),
throwsA(isA<TimeoutRejectedException>()),
);

expect(captured?.isCancelled, isTrue);

final dioToken = captured!.toDioCancelToken();
await Future.microtask(() {});
expect(dioToken.isCancelled, isTrue);
});

Next steps