Ketergantungan langsung pada API external editor atau parser dokumen pihak ketiga membawa risiko sistemik. Jika vendor mengalami outage, merilis breaking changes, atau menarik endpoint (HTTP 404/503), aplikasi internal berisiko gagal memproses data dokumen. Solusinya adalah membangun lapisan isolasi kontrak (Anti-Corruption Layer) yang memisahkan model domain aplikasi dari skema eksternal, dilengkapi mekanisme circuit breaker dan parser cadangan (fallback) lokal.

Prinsip Isolasi Kontrak Domain

Jangan mengekspos representasi data vendor ke seluruh modul internal. Definisikan antarmuka dokumen internal yang stabil, lalu gunakan adapter untuk mengonversi respons eksternal ke format kanonikal tersebut. Validasi skema pada level runtime wajib diterapkan untuk mencegah kebocoran payload yang rusak ke lapisan logika bisnis.

// domain/document.ts
export interface NormalizedDocument {
  id: string;
  content: string;
  renderedHtml: string;
  source: 'remote' | 'fallback_local';
  version: number;
}

export interface DocumentEditorPort {
  parseAndRender(rawContent: string, docId: string): Promise<NormalizedDocument>;
}

Implementasi Circuit Breaker dan Fallback Parser

Berikut implementasi adapter menggunakan Node.js dan TypeScript. Adapter memvalidasi payload upstream menggunakan skema ketat, membatasi retry dengan exponential backoff, serta otomatis berpindah ke parser lokal saat status HTTP 404, 503, atau kegagalan berulang terjadi.

// infra/resilientEditorAdapter.ts
import { DocumentEditorPort, NormalizedDocument } from '../domain/document';

interface RemoteEditorResponse {
  doc_uuid: string;
  body_markdown: string;
  html_output: string;
  v: number;
}

enum CircuitState { CLOSED, OPEN, HALF_OPEN }

export class ResilientEditorAdapter implements DocumentEditorPort {
  private state: CircuitState = CircuitState.CLOSED;
  private failureCount = 0;
  private lastFailureTime = 0;
  private readonly threshold = 3;
  private readonly resetTimeoutMs = 30000;

  constructor(private readonly remoteEndpoint: string) {}

  async parseAndRender(rawContent: string, docId: string): Promise<NormalizedDocument> {
    if (this.isCircuitOpen()) {
      return this.executeLocalFallback(rawContent, docId);
    }

    try {
      const remoteData = await this.fetchWithRetry(rawContent, docId, 2);
      this.resetCircuit();
      return this.normalize(remoteData);
    } catch (error) {
      this.recordFailure();
      return this.executeLocalFallback(rawContent, docId);
    }
  }

  private isCircuitOpen(): boolean {
    if (this.state === CircuitState.OPEN) {
      if (Date.now() - this.lastFailureTime > this.resetTimeoutMs) {
        this.state = CircuitState.HALF_OPEN;
        return false;
      }
      return true;
    }
    return false;
  }

  private recordFailure(): void {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    if (this.failureCount >= this.threshold) {
      this.state = CircuitState.OPEN;
    }
  }

  private resetCircuit(): void {
    this.failureCount = 0;
    this.state = CircuitState.CLOSED;
  }

  private async fetchWithRetry(content: string, id: string, retries: number): Promise<RemoteEditorResponse> {
    const url = `${this.remoteEndpoint}/v1/render`;
    
    for (let attempt = 0; attempt <= retries; attempt++) {
      try {
        const response = await fetch(url, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ document_id: id, text: content }),
          signal: AbortSignal.timeout(3000)
        });

        if (response.status === 404 || response.status >= 500) {
          throw new Error(`Upstream error: HTTP ${response.status}`);
        }

        const data = await response.json();
        this.assertValidSchema(data);
        return data as RemoteEditorResponse;
      } catch (err) {
        if (attempt === retries) throw err;
        await new Promise((res) => setTimeout(res, 500 * Math.pow(2, attempt)));
      }
    }
    throw new Error('Retries exhausted');
  }

  private assertValidSchema(data: any): asserts data is RemoteEditorResponse {
    if (
      typeof data !== 'object' ||
      data === null ||
      typeof data.doc_uuid !== 'string' ||
      typeof data.html_output !== 'string'
    ) {
      throw new Error('Contract violation: Invalid response schema');
    }
  }

  private executeLocalFallback(rawContent: string, docId: string): NormalizedDocument {
    // ponytail: basic regex escaping, upgrade to full local markdown parser if AST is required
    const sanitized = rawContent
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;');

    return {
      id: docId,
      content: rawContent,
      renderedHtml: `<div class="fallback-view"><pre>${sanitized}</pre></div>`,
      source: 'fallback_local',
      version: 0
    };
  }

  private normalize(data: RemoteEditorResponse): NormalizedDocument {
    return {
      id: data.doc_uuid,
      content: data.body_markdown,
      renderedHtml: data.html_output,
      source: 'remote',
      version: data.v
    };
  }
}

Trade-offs dan Pertimbangan Teknis

Menerapkan isolasi kontrak dan fallback lokal memiliki konsekuensi arsitektural yang perlu dievaluasi:

  • Feature Parity Gap: Fallback lokal yang menggunakan engine sederhana (misal parser markdown murni) seringkali tidak mendukung ekstensi khusus milik vendor (seperti formula LaTeX, syntax highlighting kustom, atau embed widget). Aplikasi harus menandai output sebagai status degraded (misal via field source: 'fallback_local').
  • Contract Drift: Vendor dapat mengubah response shape tanpa mengubah status HTTP (misal 200 OK dengan format error payload). Pengecekan assertValidSchema memastikan error langsung ditangkap dan diarahkan ke fallback daripada memicu runtime exception di UI layer.
  • State Oscillation (Flapping): Mengatur resetTimeoutMs terlalu rendah dapat membebani remote API yang baru saja pulih. Gunakan state HALF_OPEN untuk mengirim satu request uji coba (canary) sebelum membuka traffic penuh kembali.