Repository navigation
[framework-context] request-scope 정리 실패 Problem이 Error 인스턴스를 extensions에 넣어 InvalidExtensionsProblem으로 바뀌고 원래 요청 오류가 사라진다 #3006
Description
Priority: P2
Problem
Context.run()은 요청이 끝나면 request-scoped provider를 dispose하고, dispose가 실패하면 framework-context/request-scope-disposal-failed Problem을 만듭니다. 이때 catch한 Error 인스턴스 배열을 그대로 extensions.cleanupFailures에 넣습니다. Problem 생성자는 JSON-safe plain object인 extensions만 허용하므로(#2006), 이 Problem은 만들어지는 순간 InvalidExtensionsProblem(problems-core/invalid-extensions, category BadRequest)을 던집니다.
그 결과:
fn이 성공한 요청에서 정리가 실패하면Context.run()은request-scope-disposal-failed(status 500) 대신problems-core/invalid-extensions(status 400)로 거부됩니다. 서버 쪽 정리 실패가 입력 오류 category로 표현되고, 어느 provider가 왜 실패했는지는 남지 않습니다.fn이 이미 실패한 요청에서 정리까지 실패하면 원래 오류가InvalidExtensionsProblem으로 바뀝니다. 원래 오류를 그대로 던지고 정리 실패는reportRequestCleanupFailure로 보고하려던 분기(Context.ts:111-112)는 실행되지 않습니다.
transports-http의 요청 처리와 Context.run()을 직접 호출하는 경로(protocols-trpc의 createTrpcRouter, transports-graphql의 GraphQLServer, @croco/testing의 runWithTestingContext, search-core의 SearchAutoSync)가 이 오류를 그대로 받습니다.
Evidence
기준: trunk@1e313a1
packages/framework-context/src/libs/Context.ts:94-112catch한 값을 그대로 extensions에 넣습니다.
const failures: unknown[] = [];
for (const dispose of [...data.scopedDisposables.values()].reverse()) {
try {
dispose();
} catch (error) {
failures.push(error);
}
}
data.scopedDisposables.clear();
data.scopedCache.clear();
if (failures.length === 0) return;
const cleanupFailure = ProblemFactory.internalServerError(
"framework-context/request-scope-disposal-failed",
"Request-scoped provider cleanup failed.",
{ extensions: { cleanupFailures: failures } },
);
if (!failure) throw cleanupFailure;
Context.reportRequestCleanupFailure(data.context, failure.error, cleanupFailure);packages/problems-core/src/libs/Problem.ts:63-66생성자가copyExtensionsOrThrow로 extensions를 검증하고, 실패하면InvalidExtensionsProblem을 던집니다.packages/problems-core/src/libs/validators/copyProblemExtensions.ts:164-165plain object가 아닌 값(Error인스턴스 포함)을must be a plain object로 거부합니다.- 같은 패키지의 container scope 정리 경로는 이미 JSON-safe 형태를 씁니다:
packages/framework-context/src/libs/Container.ts:130-142createContainerScopeCleanupFailure가 실패를{ phase, code, message }로 바꾼 뒤 extensions에 넣습니다. Context.run()호출부:packages/transports-http/src/libs/CrocoRouteRegistrar.ts:105,packages/protocols-trpc/src/libs/createTrpcRouter.ts:180,packages/transports-graphql/src/libs/GraphQLServer.ts:146,:164,packages/testing/src/libs/testing.ts:484,packages/search-core/src/libs/sync/SearchAutoSync.ts:134.packages/*/src/tests에는request-scope-disposal-failed경로를 검사하는 테스트가 없습니다.
Reproduction
packages/framework-context/src/tests/RequestScoped.spec.ts에 추가할 테스트(afterEach, GENERATED_DI_GRAPH_VERSION, defineGeneratedDiGraph import를 추가합니다):
class FailingCleanupService {
[Symbol.dispose](): void {
throw new Error("connection release failed");
}
}
function installFailingRequestProvider(): void {
Container.installGeneratedGraph(
defineGeneratedDiGraph({
version: GENERATED_DI_GRAPH_VERSION,
graphId: "test.request-cleanup-failure",
compilerVersion: "test",
inputHash: "input",
roots: [FailingCleanupService],
providers: [
{
token: FailingCleanupService,
tokenId: "app:FailingCleanupService",
debugName: "FailingCleanupService",
scope: "request",
dependencies: [],
factory: () => new FailingCleanupService(),
sourceLocation: { file: "src/services.ts", line: 1, column: 1 },
},
],
}),
);
}
describe("request scope cleanup failure", () => {
afterEach(() => {
Container.reset();
});
it("rejects with request-scope-disposal-failed when cleanup fails after a successful request", async () => {
installFailingRequestProvider();
const rejection = await Promise.resolve(
Context.run({ requestId: "success" }, async () => {
Container.get(FailingCleanupService);
return "ok";
}),
).catch((error: { code?: string; status?: number }) => ({ code: error.code, status: error.status }));
expect(rejection).toEqual({ code: "framework-context/request-scope-disposal-failed", status: 500 });
});
it("keeps the primary request error when cleanup also fails", async () => {
installFailingRequestProvider();
const primary = new Error("handler failed");
const rejection = await Promise.resolve(
Context.run({ requestId: "failure" }, async () => {
Container.get(FailingCleanupService);
throw primary;
}),
).catch((error: unknown) => error);
expect(rejection).toBe(primary);
});
});실행: pnpm --filter @croco/framework-context exec vitest run src/tests/RequestScoped.spec.ts
관찰(trunk@1e313a1):
× request scope cleanup failure > rejects with request-scope-disposal-failed when cleanup fails after a successful request
- "code": "framework-context/request-scope-disposal-failed",
- "status": 500,
+ "code": "problems-core/invalid-extensions",
+ "status": 400,
× request scope cleanup failure > keeps the primary request error when cleanup also fails
AssertionError: expected InvalidExtensionsProblem: Invalid Problem… { …(6) } to be Error: handler failed // Object.is equality
+ "message": "Invalid Problem extensions at extensions[\"cleanupFailures\"][0]: must be a plain object",
+ "code": "problems-core/invalid-extensions",
+ "category": "BadRequest",
기대: 성공한 요청은 framework-context/request-scope-disposal-failed(500)로 거부되고, 실패한 요청은 원래 오류 그대로 거부되며 정리 실패는 따로 보고됩니다.
Desired outcome
request-scope 정리 실패는 항상 framework-context/request-scope-disposal-failed Problem으로 표현되고, 요청이 이미 실패한 경우 원래 오류 객체가 그대로 전파됩니다. 각 정리 실패의 원인은 JSON-safe 형태로 extensions에 남습니다.
Implementation path
Context.disposeRequestScope(Context.ts:94-112)에서 catch한 값을{ name, message }같은 plain object로 바꾼 뒤extensions.cleanupFailures에 넣습니다.Container.ts:130-142의createContainerScopeCleanupFailure와 같은 방식이며, 두 경로가 변환 함수를 공유하도록 옮겨도 됩니다.- 첫 번째 원래
Error객체는ProblemOptions.cause로 넘깁니다.reportRequestCleanupFailure(Context.ts:115-148)의 보고 방식은 바꾸지 않습니다. RequestScoped.spec.ts에 성공 경로, 실패 경로, 동기fnthrow 경로(Context.ts:63-67) 테스트를 추가합니다.Context.ts는 problem registry에 source 줄 번호가 기록된 파일이므로pnpm problem-registry:write로 registry를 갱신합니다.
Acceptance criteria
- 성공한 요청에서 provider dispose가 throw하면
Context.run()은 codeframework-context/request-scope-disposal-failed, status 500 Problem으로 거부되고,extensions.cleanupFailures에 각 실패의message가 남습니다. - 실패한 요청에서 dispose도 throw하면
Context.run()은 원래 오류 객체로 거부되고, 정리 실패는runtimeInspector.recordEvent(없으면runtime.logger.error, 둘 다 없으면console.error)로 한 번 보고됩니다. - 동기
fnthrow 경로도 같은 규칙을 따릅니다. - 이 경로에서
problems-core/invalid-extensions가 발생하지 않습니다.
Validation
pnpm --filter @croco/framework-context testpnpm --filter @croco/framework-context typecheckpnpm problem-registry:check
Scope boundaries
onRequestErrorhook 실패 처리([framework-context] Preserve the primary request failure when onRequestError throws #2007)는 바꾸지 않습니다.- Problem extensions의 JSON-safe 검증 규칙([problems-core] Validate Problem extensions before they can corrupt serialization #2006)은 완화하지 않습니다.
Context.run()밖으로 전파된 오류를 각 transport가 어떤 HTTP 응답으로 바꾸는지는 다루지 않습니다.framework-module의 contributionorder검증처럼 다른 호출부가 JSON-safe가 아닌 값을 extensions에 넣어 의도한 Problem이 가려지는 문제는 #3012에서 다룹니다.
Activity