Как отключить автоматический повтор провайдеров в Riverpod 3.0
По умолчанию Riverpod 3.0 повторяет упавший провайдер до 10 раз. Передайте функцию retry, возвращающую null, в ProviderScope, ProviderContainer или отдельный провайдер, чтобы отключить повтор или ограничить его.
В Riverpod 3.0 появился автоматический повтор: когда провайдер выбрасывает исключение во время сборки, Riverpod без лишнего шума повторяет его до 10 раз с экспоненциальной задержкой, которая начинается с 200ms и удваивается вплоть до 6.4 секунды. Чтобы отключить это, передайте колбэк retry, возвращающий null. Сделать это можно глобально на ProviderScope или ProviderContainer, либо для каждого провайдера отдельно, через конструктор провайдера или аннотацию @Riverpod. Проверено на flutter_riverpod 3.x (ветка 3.0 вышла в сентябре 2025 года; текущий релиз — 3.3.2, июнь 2026 года), Flutter 3.44 и Dart 3.x.
Однострочник, если вы просто хотите убрать повтор везде:
// Flutter 3.44, Dart 3.x, flutter_riverpod 3.x
ProviderScope(
retry: (retryCount, error) => null, // never retry
child: MyApp(),
)
Всё остальное в этом посте — о том, зачем существует повтор, когда значение по умолчанию действительно помогает и как ограничить его, а не убирать полностью.
Почему провайдер, который раньше падал один раз, теперь падает десять раз
В Riverpod 2.x провайдер, чей build выбрасывал исключение, сразу переходил в AsyncError и оставался там, пока что-нибудь не инвалидировало его. Одна неудача — одно состояние ошибки. Предсказуемо.
Riverpod 3.0 изменил это поведение по умолчанию. Логика здравая: многие сбои провайдеров носят временный характер. FutureProvider, вызывающий HTTP-эндпоинт, падает потому, что сеть моргнула, а не потому, что код неправильный. Повтор с задержкой означает, что интерфейс восстанавливается сам, вместо того чтобы застревать на экране ошибки, который очистило бы ручное обновление. Официальная документация описывает поведение по умолчанию как повтор “до 10 раз, с экспоненциальной задержкой от 200ms до 6.4 секунды”.
Проблема в том, что это поведение незаметно, пока не ударит по вам. Провайдер, который падает детерминированно (скажем, потому что разбирает некорректный ответ или получает 404, который никогда не станет 200), теперь сжигает все 10 попыток, прежде чем осесть в состоянии ошибки. Во время этих попыток ваш индикатор загрузки продолжает крутиться, ваши логи заполняются одним и тем же стеком вызовов десять раз, а любой побочный эффект внутри build (событие аналитики, строка лога, инкремент счётчика) срабатывает десять раз вместо одного. В тестах всё хуже: провайдер, который должен упасть быстро, вместо этого зависает, пока проигрывается расписание повторов, и ваш тест выходит по таймауту.
Воспроизведение шторма повторов
Вот минимальный провайдер, демонстрирующий это поведение. Он выбрасывает исключение безусловно и логирует каждый запуск build.
// Flutter 3.44, Dart 3.x, flutter_riverpod 3.x
import 'package:flutter_riverpod/flutter_riverpod.dart';
int _attempts = 0;
final brokenProvider = FutureProvider<int>((ref) async {
_attempts++;
print('build attempt #$_attempts');
throw StateError('this will never succeed');
});
Наблюдаем за ним из виджета:
// Flutter 3.44, Dart 3.x, flutter_riverpod 3.x
class Screen extends ConsumerWidget {
const Screen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch(brokenProvider);
return value.when(
data: (n) => Text('$n'),
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('failed: $e'),
);
}
}
На Riverpod 2.x консоль печатает build attempt #1 один раз, и виджет сразу показывает ошибку. На Riverpod 3.0 консоль печатает десять попыток, растянутых примерно на 13 секунд (200ms + 400ms + 800ms + … вплоть до 6.4s), и индикатор остаётся на экране всё это время, прежде чем наконец отрисуется ошибка. Именно этот 13-секундный разрыв между “запрос упал” и “пользователь видит ошибку” — та неожиданность, с которой большинство команд сталкивается первой.
Колбэк повтора и как возврат null его отключает
Каждый хук повтора в Riverpod 3.0 имеет одинаковую форму. Он получает текущее число повторов и ошибку, а возвращает Duration?. Верните длительность, чтобы подождать столько и попробовать снова; верните null, чтобы сдаться и показать ошибку.
// Flutter 3.44, Dart 3.x, flutter_riverpod 3.x
Duration? myRetry(int retryCount, Object error) {
if (retryCount >= 5) return null; // cap attempts
if (error is ProviderException) return null; // don't retry wrapped deps
return Duration(milliseconds: 200 * (1 << retryCount)); // 200ms, 400ms, 800ms...
}
1 << retryCount — это просто 2^retryCount, так что это воспроизводит встроенную экспоненциальную кривую. Чтобы полностью отключить повтор, вся функция сворачивается в одну строку, которая игнорирует свои аргументы и всегда возвращает null.
Отключить для всего приложения
ProviderScope — это виджет, который хранит состояние ваших провайдеров в приложении Flutter. Дайте ему retry, и каждый провайдер под ним унаследует эту политику, если не переопределит её.
// Flutter 3.44, Dart 3.x, flutter_riverpod 3.x
void main() {
runApp(
ProviderScope(
retry: (retryCount, error) => null,
child: const MyApp(),
),
);
}
В чистом Dart или везде, где вы создаёте контейнер вручную, тот же параметр находится на ProviderContainer:
// Dart 3.x, riverpod 3.x
final container = ProviderContainer(
retry: (retryCount, error) => null,
);
Отключить для одного провайдера
Глобальное отключение — грубый инструмент. Обычно вам нужен повтор для двух сетевых провайдеров, где он помогает, и его отключение для провайдера, который разбирает локальную конфигурацию и может упасть только из-за бага. Каждый конструктор провайдера принимает собственный параметр retry, и значение на уровне провайдера побеждает значение на уровне области видимости.
// Flutter 3.44, Dart 3.x, flutter_riverpod 3.x
final configProvider = FutureProvider<AppConfig>(
(ref) async => AppConfig.fromAsset(await rootBundle.loadString('config.json')),
retry: (retryCount, error) => null, // parsing bugs won't fix themselves
);
Тот же параметр есть у провайдеров на основе классов. Для NotifierProvider или AsyncNotifierProvider он располагается рядом со ссылкой на конструктор (tear-off):
// Flutter 3.44, Dart 3.x, flutter_riverpod 3.x
final todoListProvider = NotifierProvider<TodoList, List<Todo>>(
TodoList.new,
retry: (retryCount, error) => null,
);
Отключить в кодогенерируемых провайдерах
Если вы используете riverpod_generator, аннотация несёт аргумент retry. Укажите в нём именованную функцию, чтобы сгенерированный провайдер её подхватил.
// Flutter 3.44, Dart 3.x, riverpod_annotation 3.x
Duration? noRetry(int retryCount, Object error) => null;
@Riverpod(retry: noRetry)
Future<int> counter(Ref ref) async {
throw StateError('fails once, stays failed');
}
Запустите dart run build_runner build после изменения аннотации. Сгенерированный counterProvider теперь несёт политику без повторов, и вам не нужно трогать сгенерированный файл.
Что уже пропускается по умолчанию
Прежде чем отключать повтор глобально, знайте, что поведение по умолчанию не так агрессивно, как “повторять всё десять раз”. Две категории исключены из коробки.
Error (в отличие от Exception) никогда не повторяется. В Dart Error сигнализирует об ошибке программирования: провалившийся assert, проверка на null для null, некорректное приведение типа. Такие ошибки не исправить ожиданием, поэтому Riverpod показывает их сразу. Если ваш провайдер выбрасывает StateError или TypeError, повтор по умолчанию вообще не срабатывает. Провайдер brokenProvider выше выбрасывает StateError, который является подтипом Error, так что при строгом прочтении он показал бы ошибку сразу; замените его на обычный Exception, если хотите наблюдать полный шторм из десяти попыток в консоли.
ProviderException также пропускается. Когда провайдер A читает провайдер B, а B упал, Riverpod оборачивает сбой B в ProviderException, прежде чем тот достигнет A. Повторять A было бы бессмысленно, потому что сам A в порядке; восстановиться нужно именно B. Повтор по умолчанию распознаёт эту обёртку и не повторяет её, что предотвращает каскад, при котором каждый провайдер в цепочке зависимостей запускает собственное расписание повторов. Если вы когда-нибудь задавались вопросом, почему тип обёртки имеет значение, это тот же ProviderException, что стоит за сломанным try/catch, когда Riverpod 3.0 выбрасывает ProviderException вместо вашей исходной ошибки.
Так что “отключить повтор” на практике означает “перестать повторять восстановимые Exception”. Ошибки Error и сбои зависимостей и так показывались сразу.
Ограничение повтора вместо его отключения
Отключение повтора — правильный выбор для провайдеров, которые загружают локальные данные, разбирают ассеты или выполняют любую операцию, где сбой означает баг, а не заминку. Но для по-настоящему нестабильного ввода-вывода ограниченный повтор лучше, чем его отсутствие. Схема такая: ограничьте число попыток небольшим значением, пропускайте ошибки, которые заведомо постоянны, и держите короткую задержку.
// Flutter 3.44, Dart 3.x, flutter_riverpod 3.x
Duration? networkRetry(int retryCount, Object error) {
// Give up after 3 tries.
if (retryCount >= 3) return null;
// A 404 will not become a 200 by waiting.
if (error is NotFoundException) return null;
// Otherwise back off: 300ms, 600ms, 1.2s.
return Duration(milliseconds: 300 * (1 << retryCount));
}
final userProvider = FutureProvider<User>(
(ref) => api.fetchUser(),
retry: networkRetry,
);
Трёх попыток за примерно две секунды обычно достаточно, чтобы пережить временный сбой, не заставляя пользователя смотреть на индикатор 13 секунд. Значение по умолчанию в 10 попыток настроено на устойчивость в ущерб отзывчивости; большинство приложений хотят обратного компромисса для провайдеров, обращённых к пользователю.
Отключить повтор в каждом тесте
Это изменение, о котором забывает большинство команд, и оно даёт самый запутанный симптом: тест, который раньше проверял состояние ошибки, теперь выходит по таймауту. ProviderContainer, созданный обычным способом, наследует повтор по умолчанию, поэтому провайдер, который вы хотите уронить, тратит 13 секунд на повторы, прежде чем ваш expect по ошибке вообще выполнится.
Riverpod 3.0 поставляется с ProviderContainer.test — конструктором, который добавляет автоматическую очистку для тестов, и вам следует передать ему пустой retry.
// Dart 3.x, riverpod 3.x, flutter_test
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
void main() {
test('brokenProvider surfaces its error immediately', () async {
final container = ProviderContainer.test(
retry: (retryCount, error) => null,
);
await expectLater(
container.read(brokenProvider.future),
throwsA(isA<StateError>()),
);
});
}
Без переопределения retry этот тест в итоге прошёл бы, но только после полного расписания повторов, что либо превышает таймаут вашего теста, либо заставляет набор тестов ползти. Задайте пустой retry в общем тестовом хелпере, чтобы каждый контейнер получал его по умолчанию и никому не приходилось об этом помнить.
Подвох с побочными эффектами в build
Причина, по которой повтор стоит понимать, а не слепо отключать, в том, что методы build провайдеров не должны иметь внешне видимых побочных эффектов, но на практике они их часто имеют. Если ваш build логирует в аналитику, инкрементирует метрику или пишет в кэш перед тем, как упасть, каждый повтор повторяет этот побочный эффект. Десять попыток означают десять событий аналитики на один логический сбой. Ограничение повтора небольшим числом или его отключение на провайдерах, чей build не идемпотентен, сохраняет вашу телеметрию честной. Если вы обращаетесь к состоянию после await внутри этих методов, та же дисциплина, что заставляет вас проверять Ref.mounted после асинхронного разрыва, применима и к провайдерам с активными повторами, потому что повтор снова запускает всё асинхронное тело.
Ещё одна тонкость: счётчики повторов сбрасываются, когда провайдер инвалидируется и пересобирается с нуля. Бюджет в 10 попыток — на каждую непрерывную серию сбоев, а не на сессию приложения. Провайдер, который падает, исчерпывает свои повторы, инвалидируется через pull-to-refresh и падает снова, начинает свежий бюджет из 10 попыток. Если вы полагаетесь на то, что повтор в конце концов остановится, убедитесь, что инвалидация не сбрасывает его незаметно.
Выбор значения по умолчанию
Для нового приложения на Riverpod 3.0 прагматичная настройка такова: держите короткий ограниченный повтор на уровне ProviderScope для общего случая и переопределяйте отдельные провайдеры на null там, где повтор не поможет. Это даёт вам устойчивость на сетевых чтениях без 13-секундного индикатора на детерминированных сбоях.
// Flutter 3.44, Dart 3.x, flutter_riverpod 3.x
ProviderScope(
retry: (retryCount, error) {
if (retryCount >= 2) return null; // app-wide default: 3 attempts max
return Duration(milliseconds: 300 * (1 << retryCount));
},
child: const MyApp(),
)
Если вы переходите с Riverpod 2.x и хотите вернуть везде старое поведение “упал один раз — остался упавшим”, пока оцениваете эту функцию, глобальный retry: (_, __) => null — честная отправная точка. Включайте повтор обратно для каждого провайдера, как только узнаете, каким из них он действительно приносит пользу. Заметки по миграции покрывают остальное из того, что изменилось наряду с повтором, в обновлении с Riverpod 2.x до 3.0, а если вы всё ещё решаете, подходит ли Riverpod вообще, сравнение Provider vs Riverpod vs Bloc помещает это в контекст. О стороне отрисовки загрузки и ошибок тех же провайдеров смотрите, как показывать состояния загрузки и ошибки с AsyncValue.
Источники
- Automatic retry — документация Riverpod по сигнатуре колбэка повтора, значениям по умолчанию и настройке для каждого провайдера.
- What’s new in Riverpod 3.0 — анонс функции повтора и поведение задержки по умолчанию.
- Migrating from 2.0 to 3.0 — руководство по миграции, включая
ProviderContainer.test. - riverpod changelog — история версий ветки 3.x.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.