Flutter ও FastAPI সংযোগ

Dio, token refresh, FCM push notification

রোডম্যাপের মাস ৬পড়তে প্রায় ১৪ মিনিট

এটা তোমার নিজের এলাকা, আর এখানেই তোমার আলাদা শক্তি: বেশিরভাগ backend developer mobile এর সমস্যা বোঝে না, বেশিরভাগ Flutter developer backend বোঝে না। এই অধ্যায়ে দুই দিককে ঠিকভাবে জুড়বো: Dio setup, token refresh, error handling, push notification (FCM), আর দুর্বল network এ টিকে থাকা।

#API client এর গঠন

Dart (Flutter)
// lib/core/api/api_client.dart
import 'package:dio/dio.dart';

class ApiClient {
  ApiClient(this._tokens) {
    dio = Dio(BaseOptions(
      baseUrl: const String.fromEnvironment('API_URL', defaultValue: 'http://10.0.2.2:8000'),
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 20),
      headers: {'Accept': 'application/json', 'X-App-Version': appVersion},
    ));
    dio.interceptors.add(AuthInterceptor(dio, _tokens));
  }

  final TokenStore _tokens;
  late final Dio dio;
}
  • একটাই Dio instance পুরো app এ (connection reuse, কেন)
  • Base URL build এর সময় ঠিক: flutter run --dart-define=API_URL=https://api.example.com
  • Timeout সবসময়
  • App version header: server জানবে কোন version এর app, পুরনো version কে "update করুন" বলতে পারবে

#Token: নিরাপদ জায়গায়

Dart (Flutter)
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class TokenStore {
  final _s = const FlutterSecureStorage();
  Future<String?> get access => _s.read(key: 'access');
  Future<String?> get refresh => _s.read(key: 'refresh');
  Future<void> save(String a, String r) async {
    await _s.write(key: 'access', value: a);
    await _s.write(key: 'refresh', value: r);
  }
  Future<void> clear() => _s.deleteAll();
}

SharedPreferences এ token রাখবে না: rooted phone বা backup থেকে সহজে পড়া যায়। Secure storage Android Keystore আর iOS Keychain ব্যবহার করে।

#Token refresh: একবারই, সবার জন্য

সমস্যা: home screen খুললে একসাথে ৪টা request গেল, access token এর মেয়াদ শেষ, চারটাই 401 পেলো। চারটাই refresh করতে গেলে: প্রথমটা সফল, refresh token rotate হলো, বাকি তিনটা পুরনো refresh token দিয়ে চেষ্টা করলো, server ভাবলো চুরি (rotation এর নিয়ম), সব session বাতিল। User logout! 😱

সমাধান: refresh একবারই হবে, বাকিরা অপেক্ষা করবে:

Dart (Flutter)
class AuthInterceptor extends QueuedInterceptor {     // Queued: একবারে একটা error সামলায়
  AuthInterceptor(this._dio, this._tokens);
  final Dio _dio;
  final TokenStore _tokens;

  @override
  Future<void> onRequest(RequestOptions o, RequestInterceptorHandler h) async {
    final token = await _tokens.access;
    if (token != null) o.headers['Authorization'] = 'Bearer $token';
    h.next(o);
  }

  @override
  Future<void> onError(DioException e, ErrorInterceptorHandler h) async {
    final is401 = e.response?.statusCode == 401;
    final isRefreshCall = e.requestOptions.path.contains('/auth/refresh');
    if (!is401 || isRefreshCall) return h.next(e);

    // অন্য কেউ ইতিমধ্যে refresh করে ফেলেছে? তাহলে নতুন token দিয়ে আবার চেষ্টা
    final current = await _tokens.access;
    final used = (e.requestOptions.headers['Authorization'] as String?)?.replaceFirst('Bearer ', '');
    if (current != null && current != used) {
      return h.resolve(await _retry(e.requestOptions, current));
    }

    try {
      final r = await _dio.post('/v1/auth/refresh', data: {'refresh_token': await _tokens.refresh});
      await _tokens.save(r.data['access_token'], r.data['refresh_token']);
      h.resolve(await _retry(e.requestOptions, r.data['access_token']));
    } catch (_) {
      await _tokens.clear();
      authEvents.add(AuthEvent.loggedOut);          // login screen এ পাঠাও
      h.next(e);
    }
  }

  Future<Response> _retry(RequestOptions o, String token) {
    o.headers['Authorization'] = 'Bearer $token';
    return _dio.fetch(o);
  }
}

#Error: backend এর ধরন অনুযায়ী

Backend সব error একই গঠনে পাঠায় (API advanced):

Dart (Flutter)
sealed class AppFailure {
  const AppFailure(this.message);
  final String message;
}
class NetworkFailure extends AppFailure { const NetworkFailure() : super('Internet সংযোগ নেই'); }
class ValidationFailure extends AppFailure {
  const ValidationFailure(super.message, this.fields);
  final Map<String, String> fields;
}
class ServerFailure extends AppFailure {
  const ServerFailure(super.message, this.code, this.requestId);
  final String code;
  final String? requestId;          // support এ দেখানোর জন্য
}

AppFailure mapError(DioException e) {
  if (e.type == DioExceptionType.connectionError || e.type == DioExceptionType.connectionTimeout) {
    return const NetworkFailure();
  }
  final err = (e.response?.data as Map?)?['error'] as Map?;
  final rid = e.response?.headers.value('x-request-id');
  if (e.response?.statusCode == 422) {
    return ValidationFailure(err?['message'] ?? 'তথ্য ঠিক নেই',
        Map<String, String>.from(err?['fields'] ?? {}));
  }
  return ServerFailure(err?['message'] ?? 'সমস্যা হয়েছে', err?['code'] ?? 'UNKNOWN', rid);
}

UI তে code দেখে সিদ্ধান্ত (INSUFFICIENT_BALANCE হলে top-up screen এ নাও), message user কে দেখাও, আর অপ্রত্যাশিত error এ request id দেখাও যাতে user support কে জানাতে পারে।

#Model: backend এর schema থেকে

FastAPI নিজে থেকে OpenAPI spec দেয় (/openapi.json)। এখান থেকে Dart model আর client তৈরি করা যায় (openapi-generator বা swagger_dart_code_generator)। Backend বদলালে আবার generate করো, field এর নাম ভুল হওয়ার সুযোগ থাকে না। দুই দিকে একই চুক্তি: এটা AI যুগে খুব কাজের, কারণ AI কে generate করা type দিয়ে code লেখালে ভুল কম হয়।

#Push notification: FCM

App বন্ধ বা background এ থাকলে WebSocket চলে না। তখন খবর দেওয়ার উপায় push notification। Android আর iOS দুটোর জন্যই Firebase Cloud Messaging (FCM)।

Flutter app FCM token পায় FastAPI token save (device table) Celery worker push পাঠানোর job FCM (Google) phone এ পৌঁছায় ১. POST /devices (token) ২. order বদলালে job ৩. send ৪. notification
App প্রতিবার চালু হলে নিজের FCM token backend এ পাঠায়। ঘটনা ঘটলে backend (worker দিয়ে) FCM কে বলে, FCM phone এ পৌঁছে দেয়।

Flutter দিকে:

Dart (Flutter)
final token = await FirebaseMessaging.instance.getToken();
await api.dio.post('/v1/devices', data: {'fcm_token': token, 'platform': Platform.operatingSystem});
FirebaseMessaging.instance.onTokenRefresh.listen((t) => api.dio.post('/v1/devices', data: {'fcm_token': t}));

Backend দিকে:

Terminal
uv add firebase-admin
Python
# workers/push.py
import firebase_admin
from firebase_admin import credentials, messaging

firebase_admin.initialize_app(credentials.Certificate(settings.firebase_credentials_path))


@celery.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=5)
def push_to_user(user_id: int, title: str, body: str, data: dict[str, str]) -> None:
    tokens = get_device_tokens(user_id)
    if not tokens:
        return
    resp = messaging.send_each_for_multicast(messaging.MulticastMessage(
        tokens=tokens,
        notification=messaging.Notification(title=title, body=body),
        data=data,                                     # app খুললে কোন screen এ যাবে
        android=messaging.AndroidConfig(priority="high"),
    ))
    for token, r in zip(tokens, resp.responses):
        if not r.success and isinstance(r.exception, messaging.UnregisteredError):
            remove_device_token(token)                 # app uninstall হয়েছে, token বাদ

নিয়ম:

  • Push নিশ্চিত না। Phone বন্ধ, battery saver, কিছু Android brand এর কঠোর background নিয়ম। জরুরি তথ্য সবসময় app খুললে API থেকেও পাওয়া যাবে।
  • Push এ গোপন তথ্য না (OTP ছাড়া সাধারণত না, আর পুরো balance না)। Lock screen এ সবাই দেখে।
  • data তে শুধু id আর ধরন: app খুলে সেই id দিয়ে সর্বশেষ তথ্য আনবে।
  • মরা token পরিষ্কার করো।

#দুর্বল network এ টিকে থাকা

বাংলাদেশে অনেক user এর network অস্থির। কয়েকটা কৌশল:

কৌশল কীভাবে
Retry, কিন্তু বুঝে GET আর idempotency key সহ POST retry করো (backoff সহ); key ছাড়া POST কখনো নিজে থেকে retry না
Optimistic UI Note "save" চাপলে সাথে সাথে list এ দেখাও, পেছনে পাঠাও, ব্যর্থ হলে চিহ্ন দাও
Local cache শেষ পাওয়া data (Hive/Isar/Drift) দেখাও, পেছনে নতুনটা আনো (stale-while-revalidate)
Offline queue Internet না থাকলে কাজগুলো local এ জমাও, ফিরলে পাঠাও (idempotency key সহ)
ছোট response Pagination, শুধু দরকারি field, gzip, ছোট ছবি (CDN)

পুরো offline-first design Mobile System Design অধ্যায়ে।

#অনুশীলন

#নিজেকে যাচাই করো

একসাথে কয়েকটা request 401 পেলে প্রতিটা আলাদা refresh করলে কী সমস্যা?

Refresh token rotation থাকলে প্রথমটা সফল হয়ে token বদলে দেয়, বাকিরা পুরনো token দিয়ে চেষ্টা করে। Server এটাকে চুরি ভেবে সব session বাতিল করতে পারে, user logout হয়ে যায়। তাই refresh একবার হবে আর বাকিরা অপেক্ষা করবে।

Push notification এর data তে পুরো order এর তথ্য না দিয়ে শুধু id কেন?

Push এর আকার সীমিত, পৌঁছাতে দেরি হতে পারে (তখন তথ্য পুরনো), আর lock screen এ গোপনীয়তার ঝুঁকি। শুধু id দিলে app খুলে API থেকে সর্বশেষ, সঠিক তথ্য আনে।

কোন POST request app নিজে থেকে retry করতে পারে?

যেগুলোতে idempotency key আছে, কারণ server একই key এর request দ্বিতীয়বার কাজ করে না। Key ছাড়া POST retry করলে duplicate (দুইটা order, দুইবার payment) হতে পারে।

চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।