Skip to content

[framework-context] request-scope 정리 실패 Problem이 Error 인스턴스를 extensions에 넣어 InvalidExtensionsProblem으로 바뀌고 원래 요청 오류가 사라진다 #3006

Closed

Description

@kang-heewon

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-112 catch한 값을 그대로 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-165 plain object가 아닌 값(Error 인스턴스 포함)을 must be a plain object로 거부합니다.
  • 같은 패키지의 container scope 정리 경로는 이미 JSON-safe 형태를 씁니다: packages/framework-context/src/libs/Container.ts:130-142 createContainerScopeCleanupFailure가 실패를 { 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

  1. Context.disposeRequestScope(Context.ts:94-112)에서 catch한 값을 { name, message } 같은 plain object로 바꾼 뒤 extensions.cleanupFailures에 넣습니다. Container.ts:130-142의 createContainerScopeCleanupFailure와 같은 방식이며, 두 경로가 변환 함수를 공유하도록 옮겨도 됩니다.
  2. 첫 번째 원래 Error 객체는 ProblemOptions.cause로 넘깁니다. reportRequestCleanupFailure(Context.ts:115-148)의 보고 방식은 바꾸지 않습니다.
  3. RequestScoped.spec.ts에 성공 경로, 실패 경로, 동기 fn throw 경로(Context.ts:63-67) 테스트를 추가합니다.
  4. Context.ts는 problem registry에 source 줄 번호가 기록된 파일이므로 pnpm problem-registry:write로 registry를 갱신합니다.

Acceptance criteria

  • 성공한 요청에서 provider dispose가 throw하면 Context.run()은 code framework-context/request-scope-disposal-failed, status 500 Problem으로 거부되고, extensions.cleanupFailures에 각 실패의 message가 남습니다.
  • 실패한 요청에서 dispose도 throw하면 Context.run()은 원래 오류 객체로 거부되고, 정리 실패는 runtimeInspector.recordEvent(없으면 runtime.logger.error, 둘 다 없으면 console.error)로 한 번 보고됩니다.
  • 동기 fn throw 경로도 같은 규칙을 따릅니다.
  • 이 경로에서 problems-core/invalid-extensions가 발생하지 않습니다.

Validation

  • pnpm --filter @croco/framework-context test
  • pnpm --filter @croco/framework-context typecheck
  • pnpm problem-registry:check

Scope boundaries

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions