feat(ai-character): 리소스 관리 기반 정비

This commit is contained in:
Yu Sung
2026-08-01 01:30:24 +09:00
parent 55ba0df77a
commit a1cae336d9
73 changed files with 5162 additions and 816 deletions

View File

@@ -1,6 +1,6 @@
import type { z } from "zod";
import { AccessDeniedError, ApiError } from "./api-error";
import { AccessDeniedError, ApiError, UNKNOWN_API_ERROR_MESSAGE } from "./api-error";
import { getRuntimeEnv } from "../config/env";
import { createApiResponseSchema } from "./types";
@@ -32,21 +32,26 @@ function toApiError(
readonly errorProperty: string | null;
},
): ApiError {
const message = response.message.trim().length === 0 ? UNKNOWN_API_ERROR_MESSAGE : response.message;
if (status === 403) {
return new AccessDeniedError({
status,
message: response.message,
message,
errorProperty: response.errorProperty,
});
}
return new ApiError({
status,
message: response.message,
message,
errorProperty: response.errorProperty,
});
}
function unknownApiError(status: number): ApiError {
return new ApiError({ status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
}
export function createApiClient(dependencies: ApiClientDependencies): ApiClient {
let hasHandledAuthenticationExpiry = false;
let activeProtectedRequestCount = 0;
@@ -81,17 +86,36 @@ export function createApiClient(dependencies: ApiClientDependencies): ApiClient
init.body = options.body;
}
const response = await fetch(new URL(options.path, getRuntimeEnv().apiBaseUrl), init);
const parsedResponse = createApiResponseSchema(options.responseSchema).safeParse(
await response.json(),
);
let response: Response;
try {
response = await fetch(new URL(options.path, getRuntimeEnv().apiBaseUrl), init);
} catch (error) {
if (error instanceof TypeError) {
throw unknownApiError(0);
}
throw error;
}
if (response.status === 401 && isProtectedRequest && !hasHandledAuthenticationExpiry && dependencies.getToken() !== null) {
hasHandledAuthenticationExpiry = true;
dependencies.clearSession();
dependencies.onAuthExpired();
}
let responseJson: unknown;
try {
responseJson = await response.json();
} catch (error) {
if (error instanceof SyntaxError) {
throw unknownApiError(response.status);
}
throw error;
}
const parsedResponse = createApiResponseSchema(options.responseSchema).safeParse(responseJson);
if (!parsedResponse.success) {
throw new ApiError({
status: response.status,
message: "API 응답 형식이 올바르지 않습니다.",
errorProperty: null,
});
throw unknownApiError(response.status);
}
const apiResponse = parsedResponse.data;
@@ -100,23 +124,11 @@ export function createApiClient(dependencies: ApiClientDependencies): ApiClient
return apiResponse.data;
}
if (!apiResponse.success) {
if (response.status === 401 && isProtectedRequest) {
if (!hasHandledAuthenticationExpiry) {
hasHandledAuthenticationExpiry = true;
dependencies.clearSession();
dependencies.onAuthExpired();
}
}
if (!apiResponse.success) {
throw toApiError(response.status, apiResponse);
}
throw toApiError(response.status, apiResponse);
}
throw new ApiError({
status: response.status,
message: "API 오류 응답 형식이 올바르지 않습니다.",
errorProperty: null,
});
throw unknownApiError(response.status);
} finally {
if (isProtectedRequest) {
activeProtectedRequestCount -= 1;