Save my data

[Python][Selenium 3.141.0] Chromedriver 실행시 잘못된 Timeout object가 전달되는 건에 대한 분석 본문

프로젝트/Python

[Python][Selenium 3.141.0] Chromedriver 실행시 잘못된 Timeout object가 전달되는 건에 대한 분석

양을 좋아하는 문씨 2024. 6. 20. 10:52

개요 :

  • 수주 프로젝트 코딩 중 Selenium 3.141.0 버전에서 Chromedriver와 구버전 Selenium간의 호환성 문제로 의심되는 ValueError가 발생하였음
  • 이를 해결하기 위한 방법을 찾기 위해 코드를 상세히 분석함.
    • 우선 사용 환경은 다음과 같다.
      • python : 3.8
      • Selenium : 3.141.0
    • 에러 내용은 다음과 같다.
Timeout value connect was <object object at 0x00000201DBC64E50>, but it must be an int, float or None.
  • 이것에 대해 가장 쉬운 해결 방법은 Selenium4를 사용하는 것이다.
    • 그러나 의뢰주 측에서 3.141.0 버전에서 실행되는 프로그램을 원했음.
  • 어쩔 수 없이 에러 로그를 하나씩 타고 올라가며 분석을 시작했다.
Traceback (most recent call last):
  File "C:\Program Files\Python38\lib\concurrent\futures\process.py", line 239, in _process_worker
    r = call_item.fn(*call_item.args, **call_item.kwargs)
  File "f:\Github\projects\naver-kin\main.py", line 23, in make_scraper
    driver: webdriver.Chrome = Driver(executable_path=CHROME_DRIVER).make_driver()
  File "f:\Github\projects\naver-kin\modules\driver.py", line 28, in make_driver
    driver = webdriver.Chrome(
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\selenium\webdriver\chrome\webdriver.py", line 76, in __init__
    RemoteWebDriver.__init__(
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\selenium\webdriver\remote\webdriver.py", line 157, in __init__
    self.start_session(capabilities, browser_profile)
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\selenium\webdriver\remote\webdriver.py", line 252, in start_session
    response = self.execute(Command.NEW_SESSION, parameters)
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\selenium\webdriver\remote\webdriver.py", line 319, in execute
    response = self.command_executor.execute(driver_command, params)
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\selenium\webdriver\remote\remote_connection.py", line 374, in execute
    return self._request(command_info[0], url, body=data)
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\selenium\webdriver\remote\remote_connection.py", line 397, in _request
    resp = self._conn.request(method, url, body=body, headers=headers)
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\urllib3\_request_methods.py", line 144, in request
    return self.request_encode_body(
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\urllib3\_request_methods.py", line 279, in request_encode_body
    return self.urlopen(method, url, **extra_kw)
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\urllib3\poolmanager.py", line 433, in urlopen
    conn = self.connection_from_host(u.host, port=u.port, scheme=u.scheme)
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\urllib3\poolmanager.py", line 304, in connection_from_host
    return self.connection_from_context(request_context)
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\urllib3\poolmanager.py", line 329, in connection_from_context
    return self.connection_from_pool_key(pool_key, request_context=request_context)
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\urllib3\poolmanager.py", line 352, in connection_from_pool_key
    pool = self._new_pool(scheme, host, port, request_context=request_context)
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\urllib3\poolmanager.py", line 266, in _new_pool
    return pool_cls(host, port, **request_context)
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\urllib3\connectionpool.py", line 196, in __init__
    timeout = Timeout.from_float(timeout)
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\urllib3\util\timeout.py", line 186, in from_float
    return Timeout(read=timeout, connect=timeout)
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\urllib3\util\timeout.py", line 115, in __init__
    self._connect = self._validate_timeout(connect, "connect")
  File "f:\Github\projects\naver-kin\venv\lib\site-packages\urllib3\util\timeout.py", line 152, in _validate_timeout
    raise ValueError(
ValueError: Timeout value connect was <object object at 0x00000201DBC64E50>, but it must be an int, float or None.
  • 최초에 크롬 드라이버가 생성될 때 문제가 발생하고 있었다.
  • 내용이 길어 보이지만 하나씩 타고 올라가는 수 밖에 없었다.
    • 최종적인 문제는 `urllib3.util.timeout` 에서 `_validate_timeout` 로 인해 발생하는 것으로 보인다.
    • `_validate_timeout` 를 보면 다음과 같다.
@classmethod
    def _validate_timeout(cls, value: _TYPE_TIMEOUT, name: str) -> _TYPE_TIMEOUT:
        """Check that a timeout attribute is valid.

        :param value: The timeout value to validate
        :param name: The name of the timeout attribute to validate. This is
            used to specify in error messages.
        :return: The validated and casted version of the given value.
        :raises ValueError: If it is a numeric value less than or equal to
            zero, or the type is not an integer, float, or None.
        """
        if value is None or value is _DEFAULT_TIMEOUT:
            return value

        if isinstance(value, bool):
            raise ValueError(
                "Timeout cannot be a boolean value. It must "
                "be an int, float or None."
            )
        try:
            float(value)
        except (TypeError, ValueError):
            raise ValueError(
                "Timeout value %s was %s, but it must be an "
                "int, float or None." % (name, value)
            ) from None
  • 주어진 Timeout 객체의 timeout value가 `int``float` 혹은 `None`이 아니면 에러를 일으키는 것으로 보인다.
    • 여기서 한 가지 단서를 얻을 수 있었는데, 만약 Timeout 객체가 만들어지는 부분에서 value를 int나 float로 강제로 정해줄 수 있다면 몽키패치 비슷한 방법으로 고칠 수도 있겠다는 생각을 했다.
    • 그러한 부분을 염두해 둔 상태에서,
      1. Timeout이 어디서 만들어지는지,
      2. 어떤 인자를 받아서 생성되는지 찾아보았다.
  • Timeout 객체가 생성될 때의 모습을 찾아보았더니 아래와 같았다.
class Timeout:
	...
    DEFAULT_TIMEOUT: _TYPE_TIMEOUT = _DEFAULT_TIMEOUT

    def __init__(
        self,
        total: _TYPE_TIMEOUT = None,
        connect: _TYPE_TIMEOUT = _DEFAULT_TIMEOUT,
        read: _TYPE_TIMEOUT = _DEFAULT_TIMEOUT,
    ) -> None:
        self._connect = self._validate_timeout(connect, "connect")
        self._read = self._validate_timeout(read, "read")
        self.total = self._validate_timeout(total, "total")
        self._start_connect: float | None = None

    def __repr__(self) -> str:
        return f"{type(self).__name__}(connect={self._connect!r}, read={self._read!r}, total={self.total!r})"
  • 객체가 생성될 때, 생성자에 의해서 `total`, `connect`, `read` 등의 값을 받고, 유효성 검사 후 프라이빗 멤버 변수로 가져간다.
  • 이 때 값이 들어오지 않으면 `_DEFAULT_TIMEOUT` 을 기본 인수로 가져간다. `_DEFAULT_TIMEOUT` 에 대해 좀 찾아보면 다음과 같다.
class _TYPE_DEFAULT(Enum):
    # This value should never be passed to socket.settimeout() so for safety we use a -1.
    # socket.settimout() raises a ValueError for negative values.
    token = -1


_DEFAULT_TIMEOUT: Final[_TYPE_DEFAULT] = _TYPE_DEFAULT.token

_TYPE_TIMEOUT = typing.Optional[typing.Union[float, _TYPE_DEFAULT]]
  • `_DEFAULT_TIMEOUT``_TYPE_DEFAULT` 타입의 변수이고, `_TYPE_DEFAULT``Enum` 객체이다.
  • 이 때 설명을 보면, 이 값은 `socket.settimeout()` 에 전달되어서는 안되므로, 안전을 위해 `-1` 을 할당하였다고 한다. 그리고 이 값이 전달될 경우 ValueError를 발생시킨다고 되어 있다.
    • 근데 그러면 `_DEFAULT_TIMEOUT` 에서 `_TYPE_DEFAULT.token` 이 아니고 `_TYPE_DEFAULT.token.value` 로 작성해야 하는 것 아닌가?
    • 어쨌든 문자열 값이던 음수 값이던 ValueError가 발생하는 것은 마찬가지라서, 지금 그걸 따지는 건 큰 의미는 없어 보이긴 했다.
  • 다시 돌아와서, 어떤 Timeout value 가 만들어지길래 유효성 검사를 통과하지 못하는지, 확인하기 위해 Timeout 객체가 만들어지는 부분을 찾을 필요가 있어보였다.
  • 그런데 그곳을 확인하기 전에, 위의 에러 로그를 보면 우리가 확인한 `__init__``self._validate_timeout` 가 호출되는 바로 전 단계에, `Timeout` 의 클래스메서드인 `from_float` 가 호출되는 것을 알 수 있다.
@classmethod
    def from_float(cls, timeout: _TYPE_TIMEOUT) -> Timeout:
        """Create a new Timeout from a legacy timeout value.

        The timeout value used by httplib.py sets the same timeout on the
        connect(), and recv() socket requests. This creates a :class:`Timeout`
        object that sets the individual timeouts to the ``timeout`` value
        passed to this function.

        :param timeout: The legacy timeout value.
        :type timeout: integer, float, :attr:`urllib3.util.Timeout.DEFAULT_TIMEOUT`, or None
        :return: Timeout object
        :rtype: :class:`Timeout`
        """
        return Timeout(read=timeout, connect=timeout)
  • 이 부분은 문제 해결에 있어서 매우 중요해보였다. 새로운 Timeout 객체를 반환하는데, 해당 메서드의 주석을 읽어보면 핵심은 다음과 같다.
    • 레거시 타임아웃 값으로부터 새로운 타임아웃 객체를 만든다.
    • timeout 타입은 `int`, `float`, `urllib3.util.Timeout.DEFAULT_TIMEOUT`, `None` 타입을 가질 수 있다.
  • `return Timeout(read=timeout, connect=timeout)` 부분을 주목하였다.
    • 이 부분만 떼어다가 다음과 같이 수정하면 정상적인 작동을 하지 않을까 생각했다.
    • 다음과 같이 수정하였다.
from urllib3.util.timeout import Timeout
class FixedTimeout(Timeout):
    @classmethod
    def from_float(cls, timeout) -> Timeout:
        # Timeout은 urllib3.util.Timeout의 기본값을 따릅니다.
        # >>> timeout = urllib3.util.Timeout(connect=2.0, read=7.0)
        return Timeout(read=7.0, connect=2.0)
  • 이제 이 수정된 코드를 chromedriver가 생성될 때 덮어씌우면 문제가 해결된다.
class Driver:
    def __init__(self, *args: (str), **kwargs) -> None:
        timeout.Timeout.from_float = FixedTimeout.from_float # 땜질한 부분.
        naver_kin_logger.info(f"Driver generated.")
        self.executable_path = kwargs["executable_path"]
        self.options = Options()
        if "debugpy" not in sys.modules: # VSCode 디버그 모드가 아닌 경우 args 추가
            for option in args:
                self.options.add_argument(option)

    def make_driver(self) -> webdriver:
        driver = webdriver.Chrome(
            executable_path=self.executable_path,
            options=self.options,
        )
        driver_version = driver.capabilities["chrome"]["chromedriverVersion"].split()[0]
        logger.info(f"Chrome driver version {driver_version}")
        return driver
  • 이것으로 급한 불은 끌 수 있었다.

다만 근본적인 원인에 대해서는 모르고 끝난 상태라 좀 찝찝함이 있었다.

나머지 부분에 대한 분석은 이 문서에 링크로 연결할 예정이다.

Comments