widget_chat is live on pub.dev — drop-in AI chat for Flutter, FlutterFlow, React & Web. Start free →

← Back to Blog
Flutter SSE 401: Dio Interceptors Don't Run on Streams

Flutter SSE 401: Dio Interceptors Don't Run on Streams

flutterdiosseauthenticationstreamingwidgetchat

Flutter SSE 401: Dio Interceptors Don't Run on Streams

You wired an AI support chat into your Flutter app, pointed it at POST https://api.widgetchat.app/v1/chat/stream, and it streams beautifully. Then a user leaves the app open for an hour, taps send, and gets an empty grey bubble that never fills. The logs show a 401. The refresh interceptor you wrote months ago, the one that works on every other call in the app, never printed a line.

The token is not the bug. The bug is that your streaming request left the auth chain before it ever reached that interceptor.

Three ways the interceptor gets skipped

1. The SSE client is not Dio at all. Most Flutter SSE snippets use package:http with http.Request plus client.send(), because you need a StreamedResponse you can decode line by line. That is a perfectly good client, and it is also a completely separate stack: your Dio instance, its BaseOptions, and every interceptor on it are simply not in the call path. This is the single most common cause of flutter dio interceptor not called on stream.

2. validateStatus was widened so you could read the error body. With ResponseType.stream you cannot read an error response the normal way, so people do this:

final res = await dio.post(
  'https://api.widgetchat.app/v1/chat/stream',
  options: Options(
    responseType: ResponseType.stream,
    validateStatus: (_) => true, // the trap
    receiveTimeout: null,        // long-lived stream, do not cut it off
  ),
  data: {'message': text},
);

Now a 401 is a successful response as far as Dio is concerned. onError never fires, so the refresh logic never runs, and you hand the UI a byte stream that closes immediately. Empty bubble, no exception.

3. The stream already escaped. Even when onError does fire, replaying is only possible while nothing has been consumed. If you returned the stream to the widget layer first and handle failures there, there is no request left to replay from inside the interceptor.

One refresher, shared by both clients

The fix is not to move SSE into Dio. It is to move the token out of both clients and into one object that both ask. A Future field is all the single-flight lock you need, and it is what solves the flutter refresh jwt race condition where five screens all 401 at once and fire five rotations.

class AuthSession {
  AuthSession(this._authApi);

  /// A bare Dio with NO auth interceptor. Refreshing through the interceptor
  /// that calls the refresher is how you get an infinite loop.
  final Dio _authApi;

  String? _accessToken;
  String? _refreshToken;
  DateTime? _expiresAt;
  Future<String>? _inFlight;

  /// Every caller, Dio or SSE, goes through here.
  Future<String> validToken({bool force = false}) {
    final token = _accessToken;
    final skew = _expiresAt?.subtract(const Duration(seconds: 30));
    if (!force && token != null && skew != null && DateTime.now().isBefore(skew)) {
      return Future.value(token);
    }
    // Single flight: the first caller starts the refresh, everyone else awaits
    // the same future. Equivalent to a Completer, with less bookkeeping.
    return _inFlight ??= _rotate().whenComplete(() => _inFlight = null);
  }

  Future<String> _rotate() async {
    final res = await _authApi.post(
      '/auth/refresh',
      data: {'refresh_token': _refreshToken},
    );
    _accessToken = res.data['access_token'] as String;
    _refreshToken = res.data['refresh_token'] as String? ?? _refreshToken;
    _expiresAt = DateTime.now()
        .add(Duration(seconds: res.data['expires_in'] as int));
    return _accessToken!;
  }
}

The 30 second skew matters more than it looks. A token that is valid when you check it can still be expired when the handshake lands on a cold server, and with streams that costs you a full round trip plus a visible stall.

The SSE client: retry the handshake exactly once

class ChatStreamClient {
  ChatStreamClient(this._session, {http.Client? client})
      : _client = client ?? http.Client();

  final AuthSession _session;
  final http.Client _client;

  Stream<String> send(String message, {required String conversationId}) async* {
    var res = await _open(message, conversationId, await _session.validToken());

    if (res.statusCode == 401) {
      await res.stream.drain<void>();
      // force: true skips the cache but still shares one in-flight rotation
      // with any Dio call that 401'd at the same moment.
      final fresh = await _session.validToken(force: true);
      res = await _open(message, conversationId, fresh);
    }

    if (res.statusCode != 200) {
      throw ChatStreamException(res.statusCode, await res.stream.bytesToString());
    }

    final lines = res.stream
        .transform(utf8.decoder)
        .transform(const LineSplitter());

    await for (final line in lines) {
      if (!line.startsWith('data:')) continue; // skip comments, event:, id:
      yield line.substring(5).trimLeft();      // one token-ish chunk
    }
  }

  Future<http.StreamedResponse> _open(
      String message, String conversationId, String token) {
    final req = http.Request(
      'POST',
      Uri.parse('https://api.widgetchat.app/v1/chat/stream'),
    )
      ..headers.addAll({
        'Authorization': 'Bearer $token',
        'Content-Type': 'application/json',
        'Accept': 'text/event-stream',
        'Cache-Control': 'no-cache',
      })
      ..body = jsonEncode({
        'message': message,
        'conversation_id': conversationId,
      });
    return _client.send(req);
  }
}

Note res.stream.drain() before the retry. An unconsumed StreamedResponse body holds the socket; drain it or you leak a connection per expiry.

Parse the yielded payload in your repository layer rather than in the transport. Keeping send() as a stream of raw data: payloads means your retry logic has exactly one job.

Why the replay cannot duplicate a message

This is the part people are nervous about, and the ordering makes it safe. Auth runs before the model does. A 401 is decided at the handshake, in the response status line, before a single data: frame is written. Zero tokens reached the UI, so the second attempt is the first attempt that ever produced output.

The guard rails that keep that true:

  • Retry only on 401, and only once. A second 401 after a fresh token means the refresh token is dead too, so sign the user out instead of looping.
  • Retry only before the first yielded chunk. Once your await for has emitted anything, the request is no longer replayable.
  • If your own backend records the user turn on receipt, attach your own idempotency key to the request body and dedupe on it server side. The auth rejection protects you from duplicate model output, not from whatever your app writes on its way out.

The Dio side, same refresher

Your normal JSON calls keep their interceptor. It just stops owning the token.

class AuthInterceptor extends Interceptor {
  AuthInterceptor(this._session, this._dio);
  final AuthSession _session;
  final Dio _dio;

  @override
  Future<void> onRequest(RequestOptions o, RequestInterceptorHandler h) async {
    o.headers['Authorization'] = 'Bearer ${await _session.validToken()}';
    h.next(o);
  }

  @override
  Future<void> onError(DioException e, ErrorInterceptorHandler h) async {
    final o = e.requestOptions;
    if (e.response?.statusCode != 401 || o.extra['retried'] == true) {
      return h.next(e);
    }
    try {
      final token = await _session.validToken(force: true);
      o.extra['retried'] = true;
      o.headers['Authorization'] = 'Bearer $token';
      h.resolve(await _dio.fetch(o));
    } catch (_) {
      h.next(e);
    }
  }
}

Because both paths call validToken, ten concurrent 401s across Dio and the chat stream produce one rotation. You do not need QueuedInterceptorsWrapper for this, and you probably do not want it: it serialises every request in the app to hide a race that the shared future actually removes. Dio is at 5.11.1 as of this writing and this pattern is stable across the 5.x line.

The subtle part: a mid-stream drop is not a 401

SSE sends its status once. After you get a 200, the token expiring changes nothing about the open connection, and the server cannot send you a 401 through it. So anything that goes wrong after the first chunk is a transport problem: a dropped socket, a backgrounded app, a proxy idle timeout.

Treat those differently:

  • Do not force a refresh. The token was valid at the handshake. Rotating burns a refresh token and fixes nothing.
  • Do not silently re-send the message. Tokens are already on screen. Replaying now really does duplicate text, which is exactly the failure the handshake retry avoids.
  • Resume, or ask. Keep the partial text in the bubble, keep a cursor of what you rendered, and surface a retry affordance. Reconnect and append rather than restart.

A clean way to encode this: the retry-once branch lives before the first yield, and nothing after the first yield is allowed to touch AuthSession. If a code path wants to refresh after a chunk has been emitted, it is in the wrong place.

While you are in there, fix the empty bubble

The reason the bug was invisible for so long is the UI. Model the assistant message with three states: connecting, streaming (first chunk received), and failed. An unauthenticated handshake then lands as a real error with a retry button instead of a bubble that waits forever. Debugging flutter chat stream expired token is much faster when the UI can tell the difference between waiting and dead.

Try WidgetChat free

WidgetChat drops an AI support chatbot into your Flutter or FlutterFlow app, answering from your own content, streaming token by token over SSE, no proprietary SDK required. You can call it from a plain HTTP client or a custom action, which is exactly why the auth pattern above is yours to own. And once text chat is in, your users can tap the mic for a real-time voice call in the same widget, with barge-in, live captions, and provider keys that stay server-side.

Try WidgetChat free and have an authenticated streaming assistant running in your app this afternoon.

Dio on pub.dev, currently at 5.11.1, the client whose interceptor chain your raw SSE request bypasses.

WidgetChat, the AI support chatbot you embed in Flutter and FlutterFlow apps, with token-by-token SSE streaming.

Author

About the author

Widget Chat is a team of developers and designers passionate about creating the best AI chatbot experience for Flutter, web, and mobile apps.

Comments

Comments are coming soon. We'd love to hear your thoughts!