canonical studio-v1.yaml(tech-log-design-package b20d7a2)이 VALIDATION_FAILED를 DOCUMENT_VALIDATION_FAILED로 개명했다 — 스켈레톤 전역 OperationalError. VALIDATION_FAILED(400)와 code 문자열이 충돌해 같은 code가 두 HTTP status를 갖던 문제를 해소한다. - generate:tech-log-contract로 vendor된 계약·생성 타입·canonical-source.json 재생성 - STUDIO_ERROR_CODES(손수 유지되는 계약 미러)의 해당 항목 개명 계약 밖 코드는 STUDIO_UNAVAILABLE로 접히므로 이 배열이 계약과 어긋나면 안 된다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
95 lines
3.5 KiB
TypeScript
95 lines
3.5 KiB
TypeScript
import type { HttpExecutionOutcome } from "../../../../adapters/http/http-execution-v3.ts";
|
|
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
|
import type { ProblemDetails } from "../../contracts/studio/contract.ts";
|
|
|
|
/** canonical studio-v1.yaml `ProblemDetails.code` enum과 1:1이다. */
|
|
export const STUDIO_ERROR_CODES = Object.freeze([
|
|
"AUTHENTICATION_REQUIRED",
|
|
"STUDIO_ACCESS_DENIED",
|
|
"DOCUMENT_NOT_FOUND",
|
|
"VERSION_CONFLICT",
|
|
"REQUEST_VALIDATION_FAILED",
|
|
"DOCUMENT_VALIDATION_FAILED",
|
|
"VALIDATION_STALE",
|
|
"PREVIEW_NOT_FOUND",
|
|
"PREVIEW_STALE",
|
|
"PREVIEW_EXPIRED",
|
|
"PUBLICATION_NOT_FOUND",
|
|
"PUBLICATION_CONFLICT",
|
|
"PUBLICATION_EVENT_NOT_FOUND",
|
|
"PUBLICATION_SNAPSHOT_NOT_FOUND",
|
|
"WARNING_ACKNOWLEDGEMENT_REQUIRED",
|
|
"IDEMPOTENCY_KEY_REUSED",
|
|
"ASSET_NOT_FOUND",
|
|
"ASSET_NOT_READY",
|
|
"ASSET_IN_USE",
|
|
"ASSET_QUARANTINED",
|
|
"PAYLOAD_TOO_LARGE",
|
|
"UNSUPPORTED_MEDIA_TYPE",
|
|
"STUDIO_UNAVAILABLE",
|
|
]) as readonly ProblemDetails["code"][];
|
|
|
|
const CODES = new Set<string>(STUDIO_ERROR_CODES);
|
|
|
|
function synthetic(
|
|
code: ProblemDetails["code"],
|
|
status: number,
|
|
detail: string,
|
|
retryable: boolean,
|
|
): StudioGatewayError {
|
|
return new StudioGatewayError({
|
|
type: `https://techlog.local/problems/${code.toLowerCase().replaceAll("_", "-")}`,
|
|
title: code,
|
|
status,
|
|
detail,
|
|
code,
|
|
retryable,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 서버가 계약 밖 코드를 보내면 도메인 코드를 지어내지 않는다. 전송 계층
|
|
* 실패와 마찬가지로 `STUDIO_UNAVAILABLE`로 접는다.
|
|
*/
|
|
export function toStudioGatewayError(
|
|
outcome: HttpExecutionOutcome<unknown, unknown>,
|
|
operationId: string,
|
|
): StudioGatewayError {
|
|
switch (outcome.kind) {
|
|
case "PROBLEM": {
|
|
// 봉투 오류는 wire에 HTTP status를 싣지 않는다 (`envelopeError`가 `status`를
|
|
// 0으로 둔다) — 실제 status는 전송 계층이 `outcome.metadata.status`로
|
|
// 이미 들고 있으므로 여기서 덮는다. `SafeResponseMetadata.status`는
|
|
// `PROBLEM` outcome에서 필수 필드다 (`http-execution-v3.ts`).
|
|
// `problem`이 falsy이거나 status가 0(봉투의 sentinel)이면 metadata로
|
|
// 덮는다 — 원래 코드처럼 `problem`을 안전하지 않게 역참조하지 않는다.
|
|
const problem = outcome.problem as ProblemDetails | undefined;
|
|
const status = problem?.status || outcome.metadata.status;
|
|
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
|
|
return new StudioGatewayError({ ...problem, status });
|
|
}
|
|
return synthetic(
|
|
"STUDIO_UNAVAILABLE",
|
|
status,
|
|
`${operationId} returned an uncontracted problem code.`,
|
|
false,
|
|
);
|
|
}
|
|
case "UNAUTHENTICATED":
|
|
return synthetic("AUTHENTICATION_REQUIRED", 401, `${operationId} requires authentication.`, false);
|
|
case "FORBIDDEN":
|
|
return synthetic("STUDIO_ACCESS_DENIED", 403, `${operationId} was denied.`, false);
|
|
case "CANCELLED":
|
|
return synthetic("STUDIO_UNAVAILABLE", 499, `${operationId} was cancelled.`, false);
|
|
case "RATE_LIMITED":
|
|
return synthetic("STUDIO_UNAVAILABLE", 429, `${operationId} was rate limited.`, true);
|
|
case "TRANSPORT_FAILURE":
|
|
return synthetic("STUDIO_UNAVAILABLE", 503, `${operationId} transport failed.`, true);
|
|
case "AUTH_INTEGRATION_FAILURE":
|
|
case "CONTRACT_VIOLATION":
|
|
return synthetic("STUDIO_UNAVAILABLE", 502, `${operationId} broke its contract.`, false);
|
|
case "SUCCESS":
|
|
throw new Error(`${operationId}: success outcome is not an error`);
|
|
}
|
|
}
|