Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions pytest_httpserver/httpserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import threading
import time
import urllib.parse
import urllib.request
from collections import defaultdict
from collections.abc import Callable
from collections.abc import Generator
Expand All @@ -18,6 +19,7 @@
from contextlib import suppress
from copy import copy
from enum import Enum
from http import HTTPStatus
from re import Pattern
from typing import TYPE_CHECKING
from typing import Any
Expand Down Expand Up @@ -475,7 +477,7 @@ class RequestHandlerBase(abc.ABC):
def respond_with_json(
self,
response_json: Any,
status: int = 200,
status: int = HTTPStatus.OK.value,
headers: Mapping[str, str] | None = None,
content_type: str = "application/json",
) -> None:
Expand All @@ -494,7 +496,7 @@ def respond_with_json(
def respond_with_data(
self,
response_data: str | bytes = "",
status: int = 200,
status: int = HTTPStatus.OK.value,
headers: HEADERS_T | None = None,
mimetype: str | None = None,
content_type: str | None = None,
Expand Down Expand Up @@ -938,6 +940,9 @@ class HTTPServer(HTTPServerBase): # pylint: disable=too-many-instance-attribute

:param threaded: whether to handle concurrent requests in separate threads

:param startup_timeout: maximum time in seconds to wait for server readiness.
By default, no readiness check is performed.

.. py:attribute:: no_handler_status_code

Attribute containing the http status code (int) which will be the response
Expand All @@ -956,6 +961,7 @@ def __init__(
default_waiting_settings: WaitingSettings | None = None,
*,
threaded: bool = False,
startup_timeout: float | None = None,
) -> None:
"""
Initializes the instance.
Expand All @@ -972,6 +978,32 @@ def __init__(
self.default_waiting_settings = WaitingSettings()
self._waiting_settings = copy(self.default_waiting_settings)
self._waiting_result: queue.LifoQueue[bool] = queue.LifoQueue(maxsize=1)
self.startup_timeout = startup_timeout
self._readiness_check_pending = False

def start(self) -> None:
super().start()
self._readiness_check_pending = self.startup_timeout is not None
try:
self.wait_for_server_ready()
except Exception:
self.stop()
raise

def wait_for_server_ready(self) -> None:
"""
Waits until the server is ready to serve requests.
"""
if not self._readiness_check_pending:
return

url = self.url_for("/")
if not url.startswith(("http://", "https://")):
raise ValueError(f"Invalid URL generated for readiness check : {url}") # noqa: EM102

with urllib.request.urlopen(url, timeout=self.startup_timeout) as resp: # noqa: S310
if resp.status != HTTPStatus.OK.value or resp.read() != b"OK":
raise HTTPServerError("Readiness check failed with status code: {}".format(resp.status))

def clear(self) -> None:
"""
Expand Down Expand Up @@ -1272,6 +1304,10 @@ def dispatch(self, request: Request) -> Response:
:param request: the request object from the werkzeug library
:return: the response object what the handler responded, or a response which contains the error
"""
if self._readiness_check_pending:
self._readiness_check_pending = False

return Response(HTTPStatus.OK.phrase, status=HTTPStatus.OK.value)

if self.permanently_failed:
return self.respond_permanent_failure()
Expand Down
124 changes: 124 additions & 0 deletions tests/test_readiness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from collections.abc import Generator
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote a test.

from collections.abc import Generator

import pytest
import requests

from pytest_httpserver.httpserver import HTTPServer


@pytest.fixture
def httpserver_with_readiness() -> Generator[HTTPServer, None, None]:
    with HTTPServer(startup_timeout=10) as server:
        yield server


@pytest.fixture
def httpserver_without_readiness() -> Generator[HTTPServer, None, None]:
    with HTTPServer() as server:
        yield server


@pytest.mark.parametrize(
    ("startup_timeout", "expected_timeout"),
    [
        (10, 10),
        (None, None),
    ],
    ids=["with_timeout", "without_timeout"],
)
def test_httpserver_startup_timeout_attribute(startup_timeout: float | None, expected_timeout: float | None):
    with HTTPServer(startup_timeout=startup_timeout) as server:
        # Arrange
        server.expect_request("/test").respond_with_data("OK")

        # Act
        resp = requests.get(server.url_for("/test"))

        # Assert
        assert server.startup_timeout == expected_timeout
        assert resp.status_code == 200
        assert resp.text == "OK"


def test_httpserver_readiness_with_context_manager():
    with HTTPServer(startup_timeout=10) as server:
        # Arrange
        server.expect_request("/ctx").respond_with_data("context manager works")

        # Act
        resp = requests.get(server.url_for("/ctx"))

        # Assert
        assert resp.status_code == 200
        assert resp.text == "context manager works"


@pytest.mark.parametrize("num_cycles", [1, 3])
def test_httpserver_multiple_start_stop_cycles(num_cycles: int):
    # Arrange
    server = HTTPServer(startup_timeout=5)

    for i in range(num_cycles):
        # Arrange
        server.start()
        server.expect_request(f"/cycle{i}").respond_with_data(f"cycle {i}")

        # Act
        resp = requests.get(server.url_for(f"/cycle{i}"))

        # Assert
        assert resp.status_code == 200
        assert resp.text == f"cycle {i}"

        # Cleanup
        server.clear()
        server.stop()


@pytest.mark.parametrize(
    ("handler_type", "path", "expected_text"),
    [
        ("permanent", "/perm", "permanent handler"),
        ("oneshot", "/oneshot", "oneshot handler"),
    ],
    ids=["permanent", "oneshot"],
)
def test_httpserver_readiness_with_handler_types(
    httpserver_with_readiness: HTTPServer, handler_type: str, path: str, expected_text: str
):
    # Arrange
    if handler_type == "permanent":
        httpserver_with_readiness.expect_request(path).respond_with_data(expected_text)
    else:
        httpserver_with_readiness.expect_oneshot_request(path).respond_with_data(expected_text)

    # Act
    resp = requests.get(httpserver_with_readiness.url_for(path))

    # Assert
    assert resp.status_code == 200
    assert resp.text == expected_text


def test_httpserver_readiness_ordered_handlers(httpserver_with_readiness: HTTPServer):
    # Arrange
    httpserver_with_readiness.expect_ordered_request("/first").respond_with_data("1")
    httpserver_with_readiness.expect_ordered_request("/second").respond_with_data("2")

    # Act
    resp1 = requests.get(httpserver_with_readiness.url_for("/first"))
    resp2 = requests.get(httpserver_with_readiness.url_for("/second"))

    # Assert
    assert resp1.status_code == 200
    assert resp1.text == "1"
    assert resp2.status_code == 200
    assert resp2.text == "2"


def test_httpserver_readiness_does_not_interfere_with_handlers(httpserver_with_readiness: HTTPServer):
    # Arrange
    httpserver_with_readiness.expect_request("/").respond_with_data("user handler")

    # Act
    resp = requests.get(httpserver_with_readiness.url_for("/"))

    # Assert
    assert resp.status_code == 200
    assert resp.text == "user handler"


def test_httpserver_wait_for_server_ready_noop_when_no_timeout(httpserver_without_readiness: HTTPServer):
    # Act & Assert (no exception raised)
    httpserver_without_readiness.wait_for_server_ready()


@pytest.mark.parametrize("startup_timeout", [10, None], ids=["with_timeout", "without_timeout"])
def test_httpserver_readiness_check_pending_flag(startup_timeout: float | None):
    # Arrange
    server = HTTPServer(startup_timeout=startup_timeout)

    # Assert (before start)
    assert server._readiness_check_pending is False  # noqa: SLF001

    # Act
    with server:
        # Assert (after start)
        assert server._readiness_check_pending is False  # noqa: SLF001


def test_httpserver_oneshot_consumed_after_readiness(httpserver_with_readiness: HTTPServer):
    # Arrange
    httpserver_with_readiness.expect_oneshot_request("/oneshot").respond_with_data("once")

    # Act
    resp1 = requests.get(httpserver_with_readiness.url_for("/oneshot"))
    resp2 = requests.get(httpserver_with_readiness.url_for("/oneshot"))

    # Assert
    assert resp1.status_code == 200
    assert resp1.text == "once"
    assert resp2.status_code == 500

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi @HayaoSuzuki ,

Thank you! I'll take a closer look. For the first sight I think some of your tests overlap with existing tests.

For example oneshot test at the end is very similar to the already defined oneshot test(s) in test/test_oneshot.py.

I'm thinking adding tests which records the calls for wait_for_server_ready() (perhaps via inheriting or "mocking" which calls the original method as well), to ensure that the server actually waited the startup.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the feedback.
You're right that parts of the previous tests overlapped with existing ones.
I revised the tests to focus only on readiness behavior by introducing a RecordingHTTPServer subclass.
The subclass records wait_for_server_ready() calls and the _readiness_check_pending flag before and after each call while still calling the original method via super().
I also removed tests that overlapped with existing oneshot/ordered/handler-type coverage.

class RecordingHTTPServer(HTTPServer):
    """HTTPServer subclass that records wait_for_server_ready() calls."""

    def __init__(self, **kwargs: Any) -> None:
        super().__init__(**kwargs)
        self.wait_for_ready_call_count = 0
        self.readiness_pending_before_wait: list[bool] = []
        self.readiness_pending_after_wait: list[bool] = []

    def wait_for_server_ready(self) -> None:
        self.wait_for_ready_call_count += 1
        self.readiness_pending_before_wait.append(self._readiness_check_pending)
        super().wait_for_server_ready()
        self.readiness_pending_after_wait.append(self._readiness_check_pending)


@pytest.fixture
def recording_server_with_timeout() -> Generator[RecordingHTTPServer]:
    with RecordingHTTPServer(startup_timeout=10) as server:
        yield server


@pytest.fixture
def recording_server_without_timeout() -> Generator[RecordingHTTPServer]:
    with RecordingHTTPServer() as server:
        yield server


def test_wait_for_server_ready_called_with_timeout(
    recording_server_with_timeout: RecordingHTTPServer,
) -> None:
    assert recording_server_with_timeout.wait_for_ready_call_count == 1
    assert recording_server_with_timeout.readiness_pending_before_wait == [True]
    assert recording_server_with_timeout.readiness_pending_after_wait == [False]


def test_wait_for_server_ready_called_without_timeout(
    recording_server_without_timeout: RecordingHTTPServer,
) -> None:
    assert recording_server_without_timeout.wait_for_ready_call_count == 1
    assert recording_server_without_timeout.readiness_pending_before_wait == [False]
    assert recording_server_without_timeout.readiness_pending_after_wait == [False]


def test_wait_for_server_ready_called_each_start_stop_cycle() -> None:
    server = RecordingHTTPServer(startup_timeout=5)
    try:
        for i in range(3):
            server.start()
            assert server.wait_for_ready_call_count == i + 1
            server.clear()
            server.stop()
    finally:
        if server.is_running():
            server.clear()
            server.stop()

    assert server.readiness_pending_before_wait == [True, True, True]
    assert server.readiness_pending_after_wait == [False, False, False]

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good for the first sight. Should I copy your code or do you want to make a commit? A commit with your name would be better I think as you deserve the credit.

Hopefully I'll have more time to work on this on the weekend.

Zsolt

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @csernazs .
I've pushed the changes I commented on to my fork and created a PR against your server-readiness-http-probe branch:
#464

Feel free to merge it when you have time.

from typing import Any

import pytest
import requests

from pytest_httpserver.httpserver import HTTPServer
from pytest_httpserver.httpserver import HTTPServerError


@pytest.fixture
def httpserver() -> Generator[HTTPServer, None, None]:
server = HTTPServer(startup_timeout=10)
server.start()
yield server
server.clear()
if server.is_running():
server.stop()


def test_httpserver_readiness(httpserver: HTTPServer):
assert httpserver.startup_timeout == 10
httpserver.expect_request("/").respond_with_data("Hello, world!")
resp = requests.get(httpserver.url_for("/"))
assert resp.status_code == 200
assert resp.text == "Hello, world!"


class RecordingHTTPServer(HTTPServer):
"""HTTPServer subclass that records wait_for_server_ready() calls."""

def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.wait_for_ready_call_count = 0
self.readiness_pending_before_wait: list[bool] = []
self.readiness_pending_after_wait: list[bool] = []

def wait_for_server_ready(self) -> None:
self.wait_for_ready_call_count += 1
self.readiness_pending_before_wait.append(self._readiness_check_pending)
super().wait_for_server_ready()
self.readiness_pending_after_wait.append(self._readiness_check_pending)


@pytest.fixture
def recording_server_with_timeout() -> Generator[RecordingHTTPServer]:
with RecordingHTTPServer(startup_timeout=10) as server:
yield server


@pytest.fixture
def recording_server_without_timeout() -> Generator[RecordingHTTPServer]:
with RecordingHTTPServer() as server:
yield server


def test_wait_for_server_ready_called_with_timeout(
recording_server_with_timeout: RecordingHTTPServer,
) -> None:
assert recording_server_with_timeout.wait_for_ready_call_count == 1
assert recording_server_with_timeout.readiness_pending_before_wait == [True]
assert recording_server_with_timeout.readiness_pending_after_wait == [False]


def test_wait_for_server_ready_called_without_timeout(
recording_server_without_timeout: RecordingHTTPServer,
) -> None:
assert recording_server_without_timeout.wait_for_ready_call_count == 1
assert recording_server_without_timeout.readiness_pending_before_wait == [False]
assert recording_server_without_timeout.readiness_pending_after_wait == [False]


def test_wait_for_server_ready_called_each_start_stop_cycle() -> None:
server = RecordingHTTPServer(startup_timeout=5)
try:
for i in range(3):
server.start()
assert server.wait_for_ready_call_count == i + 1
server.clear()
server.stop()
finally:
if server.is_running():
server.clear()
server.stop()

assert server.readiness_pending_before_wait == [True, True, True]
assert server.readiness_pending_after_wait == [False, False, False]


def test_double_start_does_not_poison_readiness_flag() -> None:
server = HTTPServer(startup_timeout=5)
server.start()
try:
with pytest.raises(HTTPServerError, match="already running"):
server.start()

assert server._readiness_check_pending is False # noqa: SLF001

server.expect_request("/test").respond_with_data("normal response")
resp = requests.get(server.url_for("/test"))
assert resp.status_code == 200
assert resp.text == "normal response"
finally:
server.clear()
if server.is_running():
server.stop()


class FailingReadinessServer(HTTPServer):
"""HTTPServer subclass whose readiness check always fails."""

def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)

def wait_for_server_ready(self) -> None:
raise HTTPServerError("Simulated readiness failure")


def test_readiness_failure_stops_server() -> None:
server = FailingReadinessServer(startup_timeout=5)
with pytest.raises(HTTPServerError, match="Simulated readiness failure"):
server.start()

assert not server.is_running()
1 change: 1 addition & 0 deletions tests/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ def test_sdist_contents(build: Build, version: str):
"test_port_changing.py",
"test_querymatcher.py",
"test_querystring.py",
"test_readiness.py",
"test_release.py",
"test_ssl.py",
"test_thread_type.py",
Expand Down