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, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
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
assertValidSchemamemastikan error langsung ditangkap dan diarahkan ke fallback daripada memicu runtime exception di UI layer. - State Oscillation (Flapping): Mengatur
resetTimeoutMsterlalu 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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!