throttled-py의 백엔드 연동 기여 (3) - store 장애를 503으로 옮기며, 프레임워크의 예외 디스패치 규칙에 맞추기

들어가며

이전 글에서는 FastAPI contrib의 첫 PR을 merge 직전까지 다듬는 과정을 정리했습니다. 기본값 계약을 끊고, 구현 모양에 의존하던 테스트를 걷어내고, 문서가 코드와 어긋나기 쉬운 부분을 줄이는 일이었습니다.

PR(#158)이 merge되면서 contrib의 큰 골격은 자리를 잡았습니다. 하지만 한 가지 경로가 아직 비어 있었습니다. 그것은 rate-limit store의 backend 서비스를 올바르게 구성하지 못했을 때의 동작이었습니다.

이번 글에서는 그 구멍을 메우는 후속 PR(#168)을 다룹니다. 주로 아래 내용을 다룰 예정입니다.

1. store가 죽으면 500 에러가 그대로 노출된다!

본 라이브러리는 store backend에 접근할 수 없을 때 StoreUnavailableError를 발생시킵니다. 그런데 FastAPI limiter wrapper에서는 이 예외가 _check(...) 밖으로 그대로 빠져나갔습니다. Starlette의 기본 unhandled exception을 리턴하는 방식이었죠.

500
text/plain; charset=utf-8
Internal Server Error

문제는 이 응답이 상황을 잘못 설명한다는 점이었습니다. 애플리케이션 로직이 잘못된 것인지, 다른 영역이 잘못된 것인지 이 500만으로는 구분할 수 없죠. 실제로는 route가 터진 것이 아니라 rate-limit backend만 일시적으로 사용할 수 없는 상태인데 말입니다. 그러다 보니 이 상황은 자연스럽게 일반 애플리케이션 버그와 구분되어야 했습니다. 그리고 로그도 함께 남겨주면 훨씬 깔끔하게 처리할 수 있겠죠. 또한 StoreUnavailableError 자체를 기존 라이브러리 설계방향에서 예외처리로 잡으려 하다보니 아래와 같이 방향을 잡았습니다.

관련 설계방향

기본 동작은 StoreUnavailableError를 HTTP 503으로 매핑한다.

HTTP/1.1 503 Service Unavailable
content-type: application/json

{"detail":"Rate limit store unavailable"}

이 경로에서는 RateLimit-*Retry-After header를 붙이지 않기로 했습니다. 라이브러리가 로그를 남기고, 의미 있는 exception을 돌려주면 충분하다고 봤습니다.

2. 정확히 일치하는 핸들러만 잡으면?

초기 구현

그리고 "정확히 일치하는" 핸들러를 잡기로 한 것도 오판이었습니다. 처음 구현한 건 limiter wrapper에서 _check(...)를 감싸는 형태였습니다.

except StoreUnavailableError as exc:
    if StoreUnavailableError in request.app.exception_handlers:
        raise
    logger.exception(_STORE_UNAVAILABLE_LOG_MSG)
    raise HTTPException(
        status_code=_STORE_UNAVAILABLE_STATUS,
        detail=_STORE_UNAVAILABLE_DETAIL,
    ) from exc

기존 제 논리는 이랬습니다.

  1. 앱에 StoreUnavailableError 핸들러가 등록되어 있으면 re-raise해서 사용자 핸들러로 보낸다. 이때 라이브러리는 로그를 남기지 않는다.
  2. 그렇지 않으면 module logger로 로그를 남기고 HTTPException(503)을 발생시킨다. 동작은 했습니다만 어딘가 께름칙해서 PR 본문에도 Known limitation으로 적어 두었습니다[1].

핵심은 StoreUnavailableError in request.app.exception_handlers라는 검사였습니다. 이 검사는 정확히 StoreUnavailableError 타입으로 등록된 핸들러만 봅니다. 사용자가 BaseThrottledError 같은 상위 클래스로 이 라이브러리의 예외를 한곳에 모아 처리하고 있어도, 이 검사는 그 핸들러를 보지 못합니다.

즉, 상위 클래스 핸들러를 등록한 사용자는 자기 핸들러 대신 라이브러리의 기본 503을 받게 됩니다.

P1 리뷰: 예외 디스패치는 클래스 계층 기반

오너는 이런 리뷰를 제게 주었습니다.

This check only respects an exact StoreUnavailableError handler, but FastAPI/Starlette exception handling is class-hierarchy based[2].

요지는 분명했습니다. FastAPI와 Starlette의 예외 처리는 클래스 계층(MRO) 기반입니다. BaseThrottledErrorException으로 등록한 핸들러도 원래라면 StoreUnavailableError를 처리할 수 있어야 합니다만, "정확히 일치하는 타입만" 봐선 FastAPI/Starlette의 동작과 어긋난다는 점이었습니다.

즉 사용자가 이 라이브러리 계열 예외를 base-class 핸들러 하나로 모아 응답 포맷, 로깅, 메트릭을 통일해 두었어도, store 장애 경로에서만 그 핸들러가 조용히 무시되는 셈이었습니다.

또한 리뷰에는 오너가 제시한 helper 제안도 함께 있었습니다.

def _has_exception_handler(app: FastAPI, exc_type: type[Exception]) -> bool:
    # 발생한 예외의 MRO를 따라가며 매칭되는 핸들러가 하나라도 있으면 re-raise
    # 아무 핸들러도 없을 때만 기본 503 경로를 태우기
    return any(cls in app.exception_handlers for cls in exc_type.__mro__)

제시한 코드를 보고 바로 쓰지는 않고, FastAPI/Starlette의 예외처리를 더 살펴보기로 했습니다.

Exception 까지 예외처리하면?

Starlette은 일반 예외 핸들러와 달리 Exception(즉 500) 핸들러를 ServerErrorMiddleware를 통해 라우팅합니다[3]. 이 미들웨어는 등록된 500 핸들러를 호출할 수는 있지만, 그 뒤에 항상 원래 예외를 raise exc로 다시 던집니다.

class ServerErrorMiddleware:
        """ 가타부타 하지 말고 Exception 처리구문만 봅시다.
        """
        except Exception as exc:
            request = Request(scope)
            if self.debug:
                # In debug mode, return traceback responses.
                response = self.debug_response(request, exc)
            elif self.handler is None:
                # Use our default 500 error handler.
                response = self.error_response(request, exc)
            else:
                # Use an installed 500 error handler.
                if is_async_callable(self.handler):
                    response = await self.handler(request, exc)
                else:
                    response = await run_in_threadpool(self.handler, request, exc)

            if not response_started:
                await response(scope, receive, send)

            # We always continue to raise the exception.
            # This allows servers to log the error, or allows test clients
            # to optionally raise the error within the test case.
            raise exc

그래서 전역 Exception 핸들러를 "선점함"으로 취급하면 문제가 생깁니다. 우리는 fallback 503을 포기했는데, Starlette은 결국 원래 StoreUnavailableError를 transport로 다시 흘려보냅니다[4]. 게다가 우리가 남기려던 outage 로그도 건너뛰게 됩니다. store 장애가 가장 안 좋은 형태로 새어 나가는 것입니다.

그래서 helper에 Exception만 의도적으로 제외하는 조건을 넣었습니다.

def _has_exception_handler(app: FastAPI, exc_type: type[Exception]) -> bool:
    """Mirror Starlette's MRO-based handler dispatch, excluding ``Exception``.

    ``Exception`` /500 handlers go through ``ServerErrorMiddleware`` and
    re-raise after handling, so they do not preempt our default 503.

    """
    return any(
        cls is not Exception and cls in app.exception_handlers
        for cls in exc_type.__mro__
    )

wrapper의 검사도 이 helper로 바꿨습니다.

except StoreUnavailableError as exc:
    if _has_exception_handler(request.app, type(exc)):
        raise
    logger.exception(_STORE_UNAVAILABLE_LOG_MSG)
    raise HTTPException(
        status_code=_STORE_UNAVAILABLE_STATUS,
        detail=_STORE_UNAVAILABLE_DETAIL,
    ) from exc

리뷰 답글에는 이 판단의 근거를 Starlette 소스 라인까지 달아 두었습니다. 추측으로 "아마 다르게 동작할 것"이라고 적는 것과, 프레임워크 소스의 특정 줄을 가리키며 "여기서 raise exc가 다시 일어난다"고 적는 것은 리뷰어 입장에서 검증 부담이 완전히 다릅니다.

이 부분이 이번 PR에서 제가 가장 신경 쓴 보강이었습니다. 리뷰 제안은 StoreUnavailableError, BaseThrottledError, 그리고 StoreUnavailableError의 하위 클래스까지 선점하게 만드는 데 충분했습니다. 하지만 그 제안을 글자 그대로 적용했다면 전역 500 핸들러가 있는 앱에서 새로운 회귀를 만들었을 것입니다. 제안의 의도를 살리되, 프레임워크가 실제로 어떻게 디스패치하는지를 직접 확인하고 빈 곳 하나를 더 막은 셈입니다.

3. P2 리뷰: 테스트는 동작을 보되 모양은 작게

두 번째 지적은 테스트 유지보수성이었습니다.

처음 테스트 파일은 시나리오 자체는 유용했지만 구현 표면에 비해 무거웠습니다. 각 케이스가 같은 FastAPI 앱을 매번 다시 조립하고, 같은 route 설정과 로그 assertion을 반복했습니다.

메인테이너 제안은 공유 helper로 모양을 줄이자는 것이었습니다.

이걸 적용하면서 테스트는 다음과 같은 모양이 됐습니다. 공유 helper로 앱을 만들고, 핸들러 타입만 바꿔 가며 같은 동작을 확인[5]합니다.

테스트 이름과 규약은 어디까지나 예시입니다!

class Test일부코드만떼옴:
    @classmethod
    @pytest.mark.parametrize(
        "handler_type",
        [
            pytest.param(StoreUnavailableError, id="exact-handler"),
            pytest.param(BaseThrottledError, id="base-class-handler"),
        ],
    )
    async def test_handler__preempts_default_503(
        cls,
        handler_type: type[Exception],
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """A handler whose class is in the raised exception's MRO
        (exact ``StoreUnavailableError`` or a base such as
        ``BaseThrottledError``) preempts the default 503 and suppresses
        the library's store-unavailable log.
        """
        app = build_unavailable_app(
            handler_type=handler_type,
            handler=_custom_503_override,
        )
        response = await call_route(app, caplog)

        assert response.status_code == HTTPStatus.BAD_GATEWAY
        assert response.json() == {"detail": "store down"}
        assert_no_store_unavailable_log(caplog)

리뷰 끝에 정리된 테스트 코드입니다. 핸들러 디스패치를 MRO 기반으로 바꿨기 때문에, 이제 exact 핸들러와 base-class 핸들러를 같은 parametrize 케이스로 묶어 검증할 수 있었습니다. 두 경우 모두 같은 관측 가능한 동작(사용자 핸들러가 선점)을 가지기 때문입니다.

거기에 _has_exception_handler의 계약을 직접 겨냥한 테스트도 추가했습니다.

특히 Exception 제외는 앞에서 설명한 의도적인 동작인 만큼 테스트 코드로도 못박아 두었습니다.

이건 이전 장에서 정리했던 "테스트는 구현 모양이 아니라 동작을 본다"는 원칙의 연장이기도 합니다. 이번에는 동작을 검증하면서도, 나중에 고칠 때 같이 건드릴 코드를 적게 만드는 데 집중했죠.

4. 503 경로가 일부러 하지 않는 것

이 PR에서 503 경로는 두 가지를 의도적으로 하지 않습니다.

rate-limit check가 store 장애로 끝났을 때는 신뢰할 수 있는 quota 상태가 없습니다. 그런 상황에서 RateLimit-*Retry-After를 붙이면 클라이언트에게 잘못된 회복 신호를 주게 됩니다. backend가 언제 돌아올지 라이브러리는 모릅니다.

사용자 핸들러가 선점할 때는 로그를 남기지 않습니다

로그는 라이브러리가 기본 503으로 응답을 떠안는 default 경로에서만 남깁니다. 사용자가 자기 핸들러로 처리하기로 했다면, 로깅과 메트릭의 책임도 그쪽에 있습니다. 라이브러리가 중복으로 outage 로그를 남기면 사용자의 관측 파이프라인과 충돌합니다.

이 두 결정은 "store 장애 상황에서 contrib이 무엇을 책임지고 무엇을 사용자에게 넘기는가"라는 지점을 잡은 것이었습니다.

리뷰를 그대로 받지 않는다는 것

이번 PR을 정리하면서 다시 느낀 것은, 좋은 리뷰 제안일수록 그대로 복사하고 싶은 유혹이 크다는 점이었습니다.

P1 리뷰의 _has_exception_handler 스케치는 정확했고, 그대로 붙여 넣어도 BaseThrottledError 케이스는 해결됐을 것입니다. 테스트도 통과했을 가능성이 높습니다. 하지만 전역 Exception 핸들러가 있는 앱에서는 조용히 새로운 회귀가 생겼을 것입니다. 그 회귀는 평소에는 안 보이다가, store가 실제로 죽는 순간에만 가장 나쁜 형태로 드러납니다.

리뷰 제안의 가치는 "이 방향이 맞다"는 신호에 있었습니다. 그 방향을 코드로 옮기는 책임은 여전히 제 쪽이었습니다. 프레임워크의 실제 동작을 소스로 확인하고 테스트로 못박는 것까지가 리뷰 적용이라고 판단해 커밋했습니다.

마치며

이번 후속 PR의 결론은 "contrib은 프레임워크의 규칙에 일관되게 행동해야 한다"였습니다.

FastAPI와 Starlette은 예외를 클래스 계층으로 디스패치합니다. contrib이 그 안에서 자기만의 예외를 다룬다면, 매칭 규칙도 프레임워크와 같은 모양이어야 사용자가 놀라지 않습니다. 정확히 일치하는 타입만 보던 첫 구현은 동작은 했지만 프레임워크의 직관과 어긋났습니다.

동시에 그 일관성에는 예외가 하나 있었습니다. Exception/500 경로는 Starlette이 ServerErrorMiddleware로 따로 처리하고 다시 던지기 때문에, 거기에 맞추려면 오히려 그 경로를 제외해야 했습니다. "프레임워크에 맞춘다"는 말이 "모든 핸들러를 똑같이 취급한다"는 뜻이 아니라는 걸 보여 준 부분이었습니다.

두번째 글 마지막에선 프레임워크에 종속되지 않는 contrib 패턴을 추출해 보겠다고 썼습니다. 이번 PR은 그 추출에 좋은 재료를 하나 더 남겼습니다. "default 동작은 곧 공개 계약이다"라는 원칙에, "contrib의 예외 처리는 host framework의 디스패치 규칙을 따른다"가 더해졌습니다. 앞으로 Flask나 DRF 같은 연동을 할 때도 각 프레임워크의 예외 디스패치를 먼저 이해하는 것이 출발점이 될 것입니다.

이것으로 FastAPI 연동 이후의 버그 수정 기여까지 다루며, FastAPI 연동 기여 시리즈를 마무리합니다.

감사합니다.


  1. https://github.com/ZhuoZhuoCrayon/throttled-py/pull/168#issue-4489177167 ↩︎

  2. https://github.com/ZhuoZhuoCrayon/throttled-py/pull/168#discussion_r3328019051 ↩︎

  3. https://github.com/Kludex/starlette/blob/1.0.0/starlette/middleware/errors.py#L165-L186 ↩︎

  4. ServerErrorMiddleware는 Starlette 미들웨어 스택의 가장 바깥에 깔립니다. 따라서 여기서 raise exc로 다시 던진 예외는 ASGI 앱 전체(app(scope, receive, send))를 빠져나가, 그 위에 있는 ASGI 서버(uvicorn 같은 protocol server, 곧 transport)까지 전파됩니다. 바깥에 더 이상 잡아 줄 레이어가 없다는 뜻입니다. FastAPI는 Starlette에 기반하므로 모든 FastAPI 앱이라면 이 미들웨어 순서를 따르죠. Starlette의 기본 미들웨어 구성은 해당 링크를 참고해주세요. ↩︎

  5. https://github.com/ZhuoZhuoCrayon/throttled-py/blob/34780e2dadda6eb84f6c47d1bc805f1075a303e2/tests/asyncio/contrib/fastapi/test_store_unavailable.py#L115-L141 ↩︎