From dd24ad65a74128054b122cb994857ab2695e08d5 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 28 Oct 2026 13:57:12 +0300 Subject: [PATCH] refactor: derive api error codes from contract (WealthLedger/backend#117) --- .../account/ui/AccountPage/AccountPage.vm.ts | 3 +- src/shared/api/client.test.ts | 6 ++- src/shared/api/client.ts | 1 - src/shared/api/generated/schema.d.ts | 7 +++- src/shared/api/problem-details.ts | 18 ++++++++- src/shared/api/resource-query.ts | 1 - src/shared/api/spec/openapi.json | 39 ++++++++++++++++++- 7 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 8d5e906..a68c0db 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -59,8 +59,7 @@ type AccountPageViewModel | { state: "loading" }; function isPortfolioAccessFailure(code?: string) { - return isApiErrorCode(code, ApiErrorCode.Forbidden) - || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) + return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) || isApiErrorCode(code, ApiErrorCode.NotFound) || isApiErrorCode(code, ApiErrorCode.TbankNotConnected); } diff --git a/src/shared/api/client.test.ts b/src/shared/api/client.test.ts index d684c0d..40bf14d 100644 --- a/src/shared/api/client.test.ts +++ b/src/shared/api/client.test.ts @@ -285,7 +285,11 @@ describe("apiClient transient read recovery", () => { http.get("*/api/v1/brokerage-accounts", () => { requests += 1; - return HttpResponse.json({ code: "FORBIDDEN" }, { status }); + // Any non-transient code; status alone must prevent the retry. + return HttpResponse.json( + { code: "INTERNAL_SERVER_ERROR", status }, + { status }, + ); }), ); configureAuthClient({ diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index 8ac77e2..bd4009c 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -166,7 +166,6 @@ function isNetworkFailure(error: unknown): boolean { function isPermanentCode(code?: string): boolean { return [ - ApiErrorCode.Forbidden, ApiErrorCode.InvalidTbankToken, ApiErrorCode.NotFound, ApiErrorCode.TbankNotConnected, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index b73622d..2bcd0de 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -731,8 +731,11 @@ export interface components { ProblemDetailsDto: { /** @example 401 */ status: number; - /** @example UNAUTHORIZED */ - code: string; + /** + * @example UNAUTHORIZED + * @enum {string} + */ + code: "BAD_REQUEST" | "INTERNAL_SERVER_ERROR" | "NOT_FOUND" | "UNAUTHORIZED" | "UNSUPPORTED_MEDIA_TYPE" | "VALIDATION_ERROR" | "EMAIL_ALREADY_REGISTERED" | "INVALID_CREDENTIALS" | "INVALID_OPERATIONS_CURSOR" | "BOND_HISTORY_START_UNAVAILABLE" | "BOND_TICKER_AMBIGUOUS" | "STOCK_HISTORY_START_UNAVAILABLE" | "STOCK_TICKER_AMBIGUOUS" | "INVALID_TBANK_TOKEN" | "TBANK_NOT_CONNECTED" | "TBANK_UNAVAILABLE" | "BANK_CARD_ARCHIVED" | "BANK_DEPENDENT_REFUNDS" | "BANK_DUPLICATE_CATEGORY" | "BANK_IMMUTABLE_OPERATION" | "BANK_INTENT_CONFLICT" | "BANK_INVALID_DATE" | "BANK_INVALID_INPUT" | "BANK_INVALID_MONEY" | "BANK_INVALID_REFUND" | "BANK_NOT_FOUND" | "BANK_OPENING_DATE_CONFLICT" | "BANK_OPERATION_DELETED" | "BANK_REFUND_LIMIT" | "BANK_VERSION_CONFLICT" | "BANK_WRITE_CONFLICT" | "MOEX_UNAVAILABLE" | "NCR_UNAVAILABLE" | "INVALID_EVENTS_PERIOD" | "INVALID_INCOME_PERIOD"; /** * @example [ * { diff --git a/src/shared/api/problem-details.ts b/src/shared/api/problem-details.ts index b0c2c63..bcea7d5 100644 --- a/src/shared/api/problem-details.ts +++ b/src/shared/api/problem-details.ts @@ -1,3 +1,5 @@ +import type { components } from "./generated/schema"; + enum ApiErrorCode { BankDependentRefunds = "BANK_DEPENDENT_REFUNDS", BankIntentConflict = "BANK_INTENT_CONFLICT", @@ -6,7 +8,6 @@ enum ApiErrorCode { BankVersionConflict = "BANK_VERSION_CONFLICT", BankWriteConflict = "BANK_WRITE_CONFLICT", BondTickerAmbiguous = "BOND_TICKER_AMBIGUOUS", - Forbidden = "FORBIDDEN", InvalidOperationsCursor = "INVALID_OPERATIONS_CURSOR", InvalidTbankToken = "INVALID_TBANK_TOKEN", NotFound = "NOT_FOUND", @@ -16,6 +17,16 @@ enum ApiErrorCode { Unauthorized = "UNAUTHORIZED", } +/** +Compile-time drift guard: collapses to `never` when any enum member is absent +from the generated ProblemDetails contract, so every call site fails to build. +*/ +type ContractApiErrorCode + = Exclude< + `${ApiErrorCode}`, + components["schemas"]["ProblemDetailsDto"]["code"] + > extends never ? ApiErrorCode : never; + function apiErrorCode(error: unknown): string | undefined { return typeof error === "object" && error !== null && "code" in error ? String(error.code) @@ -23,7 +34,10 @@ function apiErrorCode(error: unknown): string | undefined { } // Incoming codes are an open wire string; unknown values retain their fallback. -function isApiErrorCode(value: unknown, expected: ApiErrorCode): boolean { +function isApiErrorCode( + value: unknown, + expected: ContractApiErrorCode, +): boolean { return value === expected; } diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index 4476aef..ca6ad06 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -42,7 +42,6 @@ function invalidateResourceAccess( const errorCode = apiErrorCode(error); if ( [ - ApiErrorCode.Forbidden, ApiErrorCode.InvalidTbankToken, ApiErrorCode.NotFound, ApiErrorCode.TbankNotConnected, diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index eba7e13..d8fba7a 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -5204,7 +5204,44 @@ }, "code": { "type": "string", - "example": "UNAUTHORIZED" + "example": "UNAUTHORIZED", + "enum": [ + "BAD_REQUEST", + "INTERNAL_SERVER_ERROR", + "NOT_FOUND", + "UNAUTHORIZED", + "UNSUPPORTED_MEDIA_TYPE", + "VALIDATION_ERROR", + "EMAIL_ALREADY_REGISTERED", + "INVALID_CREDENTIALS", + "INVALID_OPERATIONS_CURSOR", + "BOND_HISTORY_START_UNAVAILABLE", + "BOND_TICKER_AMBIGUOUS", + "STOCK_HISTORY_START_UNAVAILABLE", + "STOCK_TICKER_AMBIGUOUS", + "INVALID_TBANK_TOKEN", + "TBANK_NOT_CONNECTED", + "TBANK_UNAVAILABLE", + "BANK_CARD_ARCHIVED", + "BANK_DEPENDENT_REFUNDS", + "BANK_DUPLICATE_CATEGORY", + "BANK_IMMUTABLE_OPERATION", + "BANK_INTENT_CONFLICT", + "BANK_INVALID_DATE", + "BANK_INVALID_INPUT", + "BANK_INVALID_MONEY", + "BANK_INVALID_REFUND", + "BANK_NOT_FOUND", + "BANK_OPENING_DATE_CONFLICT", + "BANK_OPERATION_DELETED", + "BANK_REFUND_LIMIT", + "BANK_VERSION_CONFLICT", + "BANK_WRITE_CONFLICT", + "MOEX_UNAVAILABLE", + "NCR_UNAVAILABLE", + "INVALID_EVENTS_PERIOD", + "INVALID_INCOME_PERIOD" + ] }, "errors": { "type": "array", -- 2.54.0