fix: give each API surface its own error-code enum
The public site answered every screen with the terminal error surface. Three defects stacked, and each one hid the next. The first refused the request outright: `attachCredentials` asks the Studio helper, which returns null for a profile it does not own, and the fallback below read the session and rejected anything not authenticated. Public reads declare the ANONYMOUS profile, so a signed-out visitor — the public site's entire audience — never got a request out of the browser. An anonymous profile carries no credentials by definition and must never consult the session. With requests flowing, the second surfaced: `envelopeError()` pinned `ApiError.code` to the Studio enum and all three surfaces shared it. Public and Management each declare their own enum in their own contract, so every error they returned failed validation and arrived as a CONTRACT_VIOLATION — an unclassifiable transport fault — rather than the domain error it was. A strict enum checked against the wrong surface's contract still looks strict, which is why no gate caught it. Each surface now passes its own contract's codes. The third was the not-found path: it read `status` and `code` off the problem body, but the envelope has no `status` and names the code for its surface (PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND). The HTTP status from the transport is the authoritative signal and the only one that holds across both shapes. The regression test composes the real runtime adapters against the deployed backend's actual 404 body. Neither the gateway tests (which stub the executor) nor the screen tests (which stub the gateway) cover this seam, and the whole outage lived in it. Two page-level fixes came out of the same investigation: the profile page asked for two project slugs that only ever existed in the static fixture, and the index pages held their fixed header copy behind a request that had nothing to do with it. Headers now paint immediately; only the sections that are actually waiting show a fallback, and an empty list says so instead of rendering blank.
This commit is contained in:
@@ -18,7 +18,23 @@ type QueryEntries = readonly (readonly [string, string])[];
|
||||
|
||||
const NO_PATH: PathValues = Object.freeze({});
|
||||
const NO_QUERY = Object.freeze([]) as QueryEntries;
|
||||
const PROBLEM = envelopeError();
|
||||
/** studio-management-v1.yaml `ApiError.code` enum과 1:1이다. */
|
||||
const MANAGEMENT_ERROR_CODES = Object.freeze([
|
||||
"AUTHENTICATION_REQUIRED",
|
||||
"STUDIO_ACCESS_DENIED",
|
||||
"REQUEST_VALIDATION_FAILED",
|
||||
"VERSION_CONFLICT",
|
||||
"TOPIC_NOT_FOUND",
|
||||
"TOPIC_NAME_TAKEN",
|
||||
"TOPIC_SLUG_TAKEN",
|
||||
"TOPIC_IN_USE",
|
||||
"PROJECT_NOT_FOUND",
|
||||
"PROJECT_SLUG_TAKEN",
|
||||
"PROJECT_IN_USE",
|
||||
"INTERNAL_ERROR",
|
||||
]);
|
||||
|
||||
const PROBLEM = envelopeError(MANAGEMENT_ERROR_CODES, "ManagementErrorEnvelope");
|
||||
|
||||
/** Studio 쪽과 같은 판정이다: 4xx 도메인 거절은 적용되지 않았음이 확정, 5xx·네트워크는 불확정. */
|
||||
const COMMAND_EFFECT: CommandEffectDescriptor<ProblemDetails> = Object.freeze({
|
||||
|
||||
@@ -11,7 +11,18 @@ type QueryEntries = readonly (readonly [string, string])[];
|
||||
|
||||
const NO_PATH: PathValues = Object.freeze({});
|
||||
const NO_QUERY = Object.freeze([]) as QueryEntries;
|
||||
const PROBLEM = envelopeError();
|
||||
/**
|
||||
* public-v1.yaml `ApiError.code` enum과 1:1이다. `INTERNAL_ERROR` 는 이 기능이
|
||||
* 아니라 스켈레톤 공통 처리기가 내는 코드이고, 계약이 그것까지 열거하므로 여기도
|
||||
* 열거한다 — 빠지면 500 응답이 계약 위반으로 분류된다.
|
||||
*/
|
||||
const PUBLIC_ERROR_CODES = Object.freeze([
|
||||
"PUBLIC_REQUEST_INVALID",
|
||||
"PUBLIC_RESOURCE_NOT_FOUND",
|
||||
"INTERNAL_ERROR",
|
||||
]);
|
||||
|
||||
const PROBLEM = envelopeError(PUBLIC_ERROR_CODES, "PublicErrorEnvelope");
|
||||
|
||||
function queryOf(input: Readonly<Record<string, unknown>>): QueryEntries {
|
||||
const entries: (readonly [string, string])[] = [];
|
||||
|
||||
@@ -77,14 +77,15 @@ export const envelopeData = <T>(schemaId: string): RuntimeValidator<T> =>
|
||||
.transform((envelope) => envelope.data as T) as unknown as z.ZodType<T>,
|
||||
);
|
||||
|
||||
const apiErrorSchema = z
|
||||
.object({
|
||||
code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]),
|
||||
category: z.string().min(1),
|
||||
message: z.string().min(1).max(5000),
|
||||
retryable: z.boolean(),
|
||||
})
|
||||
.loose();
|
||||
const apiErrorSchema = (codes: readonly string[]) =>
|
||||
z
|
||||
.object({
|
||||
code: z.enum(codes as unknown as [string, ...string[]]),
|
||||
category: z.string().min(1),
|
||||
message: z.string().min(1).max(5000),
|
||||
retryable: z.boolean(),
|
||||
})
|
||||
.loose();
|
||||
|
||||
/**
|
||||
* 봉투 오류를 기존 ProblemDetails 형태로 옮긴다. 앱 계층(`StudioGatewayError`)은
|
||||
@@ -100,11 +101,27 @@ const apiErrorSchema = z
|
||||
* 않는다. 지금 그 필드들을 평평하게 읽는 소비자가 없고, 분해는 실제 소비자가
|
||||
* 생겼을 때 추가할 투기적 작업이다.
|
||||
*/
|
||||
export const envelopeError = (): RuntimeValidator<ProblemDetails> =>
|
||||
/**
|
||||
* The code enum is per-surface, and getting that wrong took the public site
|
||||
* down. Public, Studio and Management each declare their own `ApiError.code`
|
||||
* enum in their own contract; this validator was pinned to the Studio list and
|
||||
* shared by all three, so every public error — `PUBLIC_RESOURCE_NOT_FOUND`
|
||||
* first among them — failed the enum, became a CONTRACT_VIOLATION rather than a
|
||||
* PROBLEM, and reached the screens as an unclassifiable failure. A visitor
|
||||
* following a link to a project that no longer exists got the terminal error
|
||||
* surface instead of a not-found page, and no gate noticed, because a strict
|
||||
* enum checked against the wrong surface's contract still looks strict.
|
||||
*
|
||||
* Each caller now passes the enum from its own contract.
|
||||
*/
|
||||
export const envelopeError = (
|
||||
codes: readonly string[] = STUDIO_ERROR_CODES as readonly string[],
|
||||
schemaId = "StudioErrorEnvelope",
|
||||
): RuntimeValidator<ProblemDetails> =>
|
||||
zodValidator<ProblemDetails>(
|
||||
"StudioErrorEnvelope",
|
||||
schemaId,
|
||||
z
|
||||
.object({ success: z.literal(false), error: apiErrorSchema, meta: metaSchema })
|
||||
.object({ success: z.literal(false), error: apiErrorSchema(codes), meta: metaSchema })
|
||||
.loose()
|
||||
.transform((envelope) => ({
|
||||
type: `https://techlog.local/problems/${envelope.error.code.toLowerCase().replaceAll("_", "-")}`,
|
||||
|
||||
Reference in New Issue
Block a user