Boffin Codersdurable_queueby Boffin Coders

Packages

durable_queue_dio

Dio integration for durable_queue: retry the HTTP failures worth retrying, wait as long as the server asks, send idempotency keys, cancel requests in flight, and queue plain HTTP requests that survive app restarts.

Install

dependencies:
  dio: ^5.11.1
  durable_queue: ^0.5.3
  durable_queue_dio: ^0.2.1
  durable_queue_sqlite: ^0.2.1 # optional: keep requests across restarts

Queue an HTTP request

final queue = DurableQueue(
  storage: SqliteQueueStorage.open('${dir.path}/queue.db'),
);
queue.registerHttpRequests(Dio(BaseOptions(baseUrl: 'https://api.example.com')));
await queue.start();

await queue.enqueue(
  HttpRequestTask(method: 'POST', url: '/messages', body: {'text': 'hi'}),
  idempotencyKey: messageId, // sent as the Idempotency-Key header
  retryPolicy: RetryPolicy.exponential(
    maxAttempts: 5,
    initialDelay: Duration(seconds: 2),
  ),
);

HttpRequestTask stores the method, URL, headers, query parameters, and a JSON or text body. Each attempt:

Pass onResponse to registerHttpRequests to read successful responses. It is not stored, so register it on every launch.

Some APIs answer a repeated request, with an idempotency key they have already seen, with 409 Conflict. If the first response was lost, the retry would fail the task although the server did the work. List such statuses in successStatusCodes to complete the task instead; onResponse receives that response:

queue.registerHttpRequests(
  dio,
  successStatusCodes: {409},
  timeout: Duration(seconds: 60),
);

timeout is passed to DurableQueue.register: an attempt that takes longer is aborted and counts as a failed attempt, which the rules retry like a Dio timeout.

Everything in an HttpRequestTask is stored as plain JSON. Do not put credentials in its headers. Add them when the request is sent, with a Dio interceptor:

final dio = Dio()
  ..interceptors.add(InterceptorsWrapper(
    onRequest: (options, handler) async {
      options.headers['Authorization'] = 'Bearer ${await auth.token()}';
      handler.next(options);
    },
  ));

Use the rules in your own tasks

For tasks you write yourself, use the same retry rules and helpers:

const rules = DioRetryRules();

queue.register<UploadPhotoTask>(
  type: 'upload_photo',
  decoder: UploadPhotoTask.fromJson,
  handler: (task, context) async {
    await dio.post(
      '/photos',
      data: FormData.fromMap({'file': await MultipartFile.fromFile(task.path)}),
      options: context.withIdempotencyKey(null),
      cancelToken: context.dioCancelToken(),
    );
  },
  retryIf: rules.shouldRetry,
  retryDelay: rules.retryDelay,
);

Store the file path in the task, not the bytes; the file is read again on each attempt.

Wait for the network

Queued requests can wait while the device is offline instead of using attempts, and go out as soon as it is back. Add a gate named network, list it in requires, and let rules.deferOn turn connection errors into a wait:

const rules = DioRetryRules();

final network = await ConnectivityGate.create(); // durable_queue_connectivity
queue.addGate('network', network);

queue.registerHttpRequests(
  dio,
  rules: rules,
  requires: {'network'},
  deferOn: rules.deferOn('network'),
);

The same options work for your own task types: pass requires: {'network'} and deferOn: rules.deferOn('network') to queue.register.

Rate limits per host

Give each API host its own budget, so one busy service cannot take every slot:

queue.addHttpHostRateLimit(
  RateLimit(5, per: Duration(seconds: 1)),
  dio: dio, // resolves relative URLs against dio.options.baseUrl
);

Rules

Error Default
Connect, send, or receive timeout Retry
Attempt longer than register(timeout:) (TaskTimeoutException) Retry
Connection error (offline, DNS, refused, reset) Retry
HTTP 408, 425, 429, 500, 502, 503, 504 Retry
Other HTTP statuses Fail
Cancelled, bad certificate, transform timeout, unknown Fail
Other errors that are not DioException Fail (combine with your own retryIf)

Retry-After (seconds or an HTTP-date) sets the wait before the next attempt, capped by maxRetryAfter (one hour by default). Without it, the task's RetryPolicy decides. Retries always count toward maxAttempts.

const rules = DioRetryRules(
  retryStatusCodes: {429, 503},
  retryTimeouts: true,
  retryConnectionErrors: true,
  useRetryAfter: true,
  maxRetryAfter: Duration(minutes: 10),
);
queue.registerHttpRequests(dio, rules: rules, idempotencyHeader: 'X-Request-Id');

Things to know

Example

dart run example/main.dart

A message is stored while "offline", the app restarts, the server answers 503 with Retry-After: 1, and the message is delivered on the next attempt.

Contributing

Issues and pull requests: github.com/boffincoders/durable_queue. See CONTRIBUTING.md.

This page is packages/durable_queue_dio/README.md in the repository. Suggest a change.