Anatomi Insiden: Schema Payload Mismatch dan Crash Massal

Insiden rilis backend yang mengubah struktur response JSON tanpa backward compatibility langsung memicu crash massal pada client mobile. Berbeda dengan web frontend yang dapat diperbarui secara instan melalui pergantian bundle di CDN, aplikasi React Native bergantung pada siklus update app store dan kebijakan auto-update perangkat pengguna. Versi lama aplikasi akan tetap aktif di lapangan selama berminggu-minggu atau berbulan-bulan.

Sebagai contoh, endpoint GET /api/v1/profile mengubah schema dari properti flat menjadi nested object:

// Schema v1.0.0 (Lama)
{
  "id": "usr_123",
  "full_name": "Budi Santoso"
}

// Schema v1.1.0 (Baru / Breaking)
{
  "id": "usr_123",
  "name": {
    "first": "Budi",
    "last": "Santoso"
  }
}

Komponen React Native pada versi lama menjalankan kode berikut:

const renderHeader = (user) => {
  // Uncaught TypeError: Cannot read property 'toUpperCase' of undefined
  return <Text>{user.full_name.toUpperCase()}</Text>;
};

Karena JavaScript engine (Hermes atau JSC) tidak menemukan fallback penanganan nilai undefined pada properti tersebut, eksepsi fatal dilempar ke root error boundary. Jika unhandled, sistem operasi akan mematikan proses aplikasi. Solusi arsitektural untuk problem ini adalah version gating: mekanisme validasi versi sebelum payload yang rusak diproses oleh logika presentasi.

Arsitektur Version Gating: Strategi Soft Update vs Hard Update

Version gating memvalidasi versi client yang sedang berjalan terhadap batas versi minimum yang diizinkan oleh backend. Terdapat dua klasifikasi penegakan versi:

  • Soft Update (Non-blocking): Menampilkan notifikasi atau banner informatif yang menyarankan pembaruan aplikasi. Digunakan ketika API memperkenalkan fitur baru namun fungsionalitas inti versi lama tetap kompatibel.
  • Hard Update (Force Update / Blocking): Menampilkan antarmuka modal yang tidak dapat ditutup, memblokir interaksi aplikasi, dan mengarahkan pengguna ke Google Play Store atau Apple App Store. Digunakan saat terjadi breaking API contract atau penambalan celah keamanan kritis.

Protokol penegakan dapat dilakukan melalui dua mekanisme HTTP:

  1. HTTP 426 Upgrade Required: Server gateway/reverse proxy menolak request dan mengembalikan status code standar RFC 2817 dengan instruksi upgrade.
  2. Header Metadata Khusus: Response sukses menyertakan header seperti X-Min-Supported-Version dan X-Latest-Version, memungkinkan client mengevaluasi versinya sendiri.
Gunakan status HTTP 426 Upgrade Required saat kontrak API benar-benar usang dan tidak dapat melayani payload lama. Gunakan response headers pada status 200 OK untuk memicu soft update.

Implementasi Interceptor Axios dan State UI Terpusat

Validasi versi harus ditangani pada lapisan transport jaringan. Implementasi interceptor Axios mencegat response 426 secara terpusat, membatalkan propagasi error ke pemanggil API lokal, dan memicu state modal force update.

import axios, { AxiosError, AxiosResponse } from 'axios';
import { NativeModules, Platform, Linking } from 'react-native';

// ponytail: minimal in-memory emitter, upgrade to zustand/redux if app-wide state needs persistence
export const versionGateState = {
  isBlocked: false,
  storeUrl: '',
  listeners: new Set<(blocked: boolean) => void>(),
  notify(blocked: boolean) {
    this.isBlocked = blocked;
    this.listeners.forEach((fn) => fn(blocked));
  },
};

export const apiClient = axios.create({
  baseURL: 'https://api.domain.com',
  headers: {
    'X-App-Version': '1.0.0', // Diambil dari react-native-device-info atau native config
    'X-App-Platform': Platform.OS,
  },
});

apiClient.interceptors.response.use(
  (response: AxiosResponse) => {
    // Evaluasi header untuk Soft Update
    const minVersion = response.headers['x-min-supported-version'];
    if (minVersion && isVersionOutdated('1.0.0', minVersion)) {
      // Emit warning untuk soft update banner
    }
    return response;
  },
  async (error: AxiosError) => {
    if (error.response && error.response.status === 426) {
      const storeUrl = Platform.select({
        ios: 'https://apps.apple.com/app/id123456789',
        android: 'market://details?id=com.domain.app',
      });

      versionGateState.storeUrl = storeUrl || '';
      versionGateState.notify(true);

      // Gagalkan promise tanpa melempar unhandled logic ke screen
      return new Promise(() => {});
    }
    return Promise.reject(error);
  }
);

function isVersionOutdated(current: string, minimum: string): boolean {
  const c = current.split('.').map(Number);
  const m = minimum.split('.').map(Number);
  for (let i = 0; i < 3; i++) {
    if ((c[i] || 0) < (m[i] || 0)) return true;
    if ((c[i] || 0) > (m[i] || 0)) return false;
  }
  return false;
}

Komponen antarmuka penegakan update diletakkan di root tree navigasi aplikasi:

import React, { useEffect, useState } from 'react';
import { Modal, View, Text, Button, BackHandler, StyleSheet, Linking } from 'react-native';
import { versionGateState } from './apiClient';

export const ForceUpdateModal = () => {
  const [visible, setVisible] = useState(versionGateState.isBlocked);

  useEffect(() => {
    const listener = (blocked: boolean) => setVisible(blocked);
    versionGateState.listeners.add(listener);
    
    // Cegah penutupan via tombol back fisik di Android
    const backHandler = BackHandler.addEventListener('hardwareBackPress', () => visible);

    return () => {
      versionGateState.listeners.delete(listener);
      backHandler.remove();
    };
  }, [visible]);

  if (!visible) return null;

  return (
    <Modal visible={visible} transparent={false} animationType="fade">
      <View style={styles.container}>
        <Text style={styles.title}>Pembaruan Wajib</Text>
        <Text style={styles.message}>
          Versi aplikasi yang Anda gunakan sudah tidak didukung. Harap lakukan pembaruan ke versi terbaru untuk melanjutkan transaksi.
        </Text>
        <Button
          title="Perbarui Sekarang"
          onPress={() => Linking.openURL(versionGateState.storeUrl)}
        />
      </View>
    </Modal>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
  title: { fontSize: 20, fontWeight: 'bold', marginBottom: 12 },
  message: { fontSize: 14, textAlign: 'center', marginBottom: 24, lineHeight: 20 },
});

Observabilitas Distribusi Versi di APM Sebelum Rilis

Mencegah breaking deployment memerlukan validasi berbasis data distribusi client aktif, bukan sekadar asumsi waktu rilis.

  1. Injeksi Client Version Header: Wajibkan setiap request dari React Native menyertakan header X-App-Version dan User-Agent terstruktur.
  2. Ingress Tagging: Konfigurasikan API Gateway (NGINX, Kong, atau Envoy) untuk meneruskan header versi aplikasi ke structured access logs dan APM (Datadog, Grafana Loki, atau New Relic).
  3. Analisis Distribusi Versi Aktif: Sebelum tim backend mematikan field payload lama atau menaikkan versi minimum, jalankan kueri agregasi APM:
# Contoh Prometheus Query untuk menghitung rasio request versi usang
sum(rate(http_requests_total{app_version=~"1.0.*"}[1h])) 
/
sum(rate(http_requests_total[1h])) * 100

Deployment breaking change hanya boleh dieksekusi jika volume traffic dari versi yang akan didegradasi berada di bawah ambang batas toleransi bisnis (misalnya < 0.1% dari total active sessions harian).

Checklist Pencegahan Deployment Lintas Tim

Terapkan protokol berikut sebelum mengeksekusi pipeline deployment backend:

  • Terapkan Pola Expand and Contract: Jangan langsung menghapus field lama. Tambahkan field baru (Expand), rilis update React Native yang mengonsumsi field baru, pantau adopsi pengguna, kemudian hapus field lama (Contract) setelah periode sunset.
  • API Schema Validation: Gunakan schema contract testing (Pact atau OpenAPI/Swagger diff validator) dalam continuous integration backend untuk mendeteksi breaking field perubahan secara otomatis.
  • Deprecation Timeline: Tetapkan Standard Operating Procedure (SOP) periode sunset minimal 30–60 hari untuk memberi ruang pengguna menyelesaikan auto-update app store.
  • Fallback UI Boundaries: Pada sisi React Native, selalu definisikan dynamic rendering dengan optional chaining (user?.name?.first) dan pasang fallback UI pada componentDidCatch / Error Boundary lokal agar crash di satu elemen tidak mematikan seluruh aplikasi.