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
2 changes: 0 additions & 2 deletions foghttp/_client/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
from ..types import AsyncMultipartFiles, QueryParams, RequestData, SyncMultipartFiles
from ..url import URL
from .config import ClientConfig
from .lifecycle_debug import AsyncLifecycleDebugTracker
from .process import current_process_id, forked_process_error
from .proxy_diagnostics_mapping import empty_proxy_diagnostics, proxy_diagnostics_from_raw
from .raw.errors import raise_public_raw_error
Expand Down Expand Up @@ -56,7 +55,6 @@ def __init__(self, *, config: ClientConfig) -> None:
self._client_lock = threading.Lock()
self._client: _foghttp.RawClient | None = None
self._telemetry = TelemetryDispatcher(config.telemetry)
self._lifecycle_debug = AsyncLifecycleDebugTracker(config.lifecycle_debug)
self._request_builder = (
_DEFAULT_REQUEST_BUILDER
if config.request_defaults is DEFAULT_REQUEST_BUILD_DEFAULTS and config.auth is None
Expand Down
49 changes: 25 additions & 24 deletions foghttp/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from ._client.constants import DEFAULT_MAX_REDIRECTS
from ._client.core import ClientCore
from ._client.lifecycle_debug import (
AsyncLifecycleDebugTracker,
LifecycleDebugRequestToken,
async_lifecycle_debug_leak_message,
)
Expand Down Expand Up @@ -81,32 +82,32 @@ def __init__(
telemetry: TelemetryConfig | None = None,
lifecycle_debug: AsyncLifecycleDebugConfig | None = None,
) -> None:
super().__init__(
config=ClientConfig.from_options(
ClientOptions(
base_url=base_url,
headers=headers,
auth=auth,
params=params,
limits=limits,
timeouts=timeouts,
http_versions=http_versions,
follow_redirects=follow_redirects,
max_redirects=max_redirects,
cookies=cookies,
trust_env=trust_env,
proxy=proxy,
tls=tls,
runtime=runtime,
runtime_workers=runtime_workers,
policy_hooks=policy_hooks,
retry=retry,
ssrf=ssrf,
telemetry=telemetry,
lifecycle_debug=lifecycle_debug,
),
config = ClientConfig.from_options(
ClientOptions(
base_url=base_url,
headers=headers,
auth=auth,
params=params,
limits=limits,
timeouts=timeouts,
http_versions=http_versions,
follow_redirects=follow_redirects,
max_redirects=max_redirects,
cookies=cookies,
trust_env=trust_env,
proxy=proxy,
tls=tls,
runtime=runtime,
runtime_workers=runtime_workers,
policy_hooks=policy_hooks,
retry=retry,
ssrf=ssrf,
telemetry=telemetry,
lifecycle_debug=lifecycle_debug,
),
)
self._lifecycle_debug = AsyncLifecycleDebugTracker(config.lifecycle_debug)
super().__init__(config=config)
self._transport = self._create_transport()

async def __aenter__(self) -> "AsyncClient":
Expand Down
38 changes: 38 additions & 0 deletions tests/client_cancellation/test_async_lifecycle_debug_buffered.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,44 @@ async def test_async_lifecycle_debug_tracks_active_buffered_request(
client.assert_no_lifecycle_leaks()


@pytest.mark.parametrize(
"debug_config",
[
pytest.param(None, id="disabled"),
pytest.param(foghttp.AsyncLifecycleDebugConfig(), id="enabled"),
pytest.param(foghttp.AsyncLifecycleDebugConfig(strict=True), id="strict"),
],
)
async def test_async_lifecycle_debug_is_isolated_between_clients(
cancellation_server: str,
debug_config: foghttp.AsyncLifecycleDebugConfig | None,
) -> None:
async with (
foghttp.AsyncClient(lifecycle_debug=debug_config) as active_client,
foghttp.AsyncClient(lifecycle_debug=debug_config) as idle_client,
):
idle_before = idle_client.dump_lifecycle_debug()
task = asyncio.create_task(active_client.get(cancellation_server + SLOW_HEADERS_PATH))
try:
predicate = has_disabled_transport_pressure if debug_config is None else has_one_buffered_request
await wait_for_lifecycle_debug(
active_client,
predicate,
message="request was not active before checking client isolation",
)

assert idle_client.dump_lifecycle_debug() == idle_before
idle_client.assert_no_lifecycle_leaks()
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
Comment thread
GefMar marked this conversation as resolved.
Dismissed

await wait_for_no_active_requests(active_client)
active_client.assert_no_lifecycle_leaks()
assert idle_client.dump_lifecycle_debug() == idle_before


async def test_async_lifecycle_debug_reports_pending_acquire_after_cancellation(
cancellation_server: str,
) -> None:
Expand Down
27 changes: 25 additions & 2 deletions tests/client_lifecycle/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from collections.abc import Iterator
from collections.abc import Callable, Iterator
import gc
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import importlib
import sys
import threading
from typing import Any
from typing import Any, Never
from urllib.parse import urlsplit

import pytest
Expand All @@ -22,6 +24,27 @@
)


@pytest.fixture
def construction_failure() -> Callable[..., Never]:
def fail(*_args: object, **_kwargs: object) -> Never:
message = "client construction failed"
raise RuntimeError(message)

return fail


@pytest.fixture
def unraisable_errors(monkeypatch: pytest.MonkeyPatch) -> list[str]:
gc.collect()
errors: list[str] = []

def capture(error: Any) -> None:
errors.append(str(error.exc_value))

monkeypatch.setattr(sys, "unraisablehook", capture)
return errors


@pytest.fixture
def raw_client() -> CloseTrackingRawClient:
return CloseTrackingRawClient()
Expand Down
68 changes: 68 additions & 0 deletions tests/client_lifecycle/test_client_construction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from collections.abc import Callable
import gc
from typing import Never
import warnings

from faker import Faker
import pytest

import foghttp
from foghttp._client.lifecycle_debug import AsyncLifecycleDebugTracker

from .helpers import CloseTrackingRawClient, RawClientFactory


@pytest.mark.usefixtures("sync_noop_transport")
def test_sync_client_does_not_require_async_lifecycle_tracker(
monkeypatch: pytest.MonkeyPatch,
construction_failure: Callable[..., Never],
sync_client_factory: type[foghttp.Client],
raw_client: CloseTrackingRawClient,
raw_client_factory: RawClientFactory,
faker: Faker,
) -> None:
monkeypatch.setattr(AsyncLifecycleDebugTracker, "__init__", construction_failure)

with sync_client_factory() as client:
client.get(faker.url())

assert raw_client_factory.calls == 1
assert raw_client.close_calls == 1


@pytest.mark.parametrize(
("failure_target", "expected_warnings"),
[
pytest.param("foghttp._client.config.ClientConfig.from_options", 0, id="config"),
pytest.param("foghttp._client.lifecycle_debug.AsyncLifecycleDebugTracker.__init__", 0, id="tracker"),
pytest.param("foghttp._client.core.TelemetryDispatcher.__init__", 1, id="base"),
pytest.param("foghttp.AsyncClient._create_transport", 1, id="adapter"),
],
)
@pytest.mark.parametrize(
"debug_config",
[
pytest.param(None, id="disabled"),
pytest.param(foghttp.AsyncLifecycleDebugConfig(), id="enabled"),
pytest.param(foghttp.AsyncLifecycleDebugConfig(strict=True), id="strict"),
],
)
def test_async_construction_failure_preserves_error_without_finalizer_error(
monkeypatch: pytest.MonkeyPatch,
construction_failure: Callable[..., Never],
unraisable_errors: list[str],
failure_target: str,
expected_warnings: int,
debug_config: foghttp.AsyncLifecycleDebugConfig | None,
) -> None:
monkeypatch.setattr(failure_target, construction_failure)

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", foghttp.UnclosedClientError)
with pytest.raises(RuntimeError, match=r"^client construction failed$"):
foghttp.AsyncClient(lifecycle_debug=debug_config)
gc.collect()

assert unraisable_errors == []
assert len(caught) == expected_warnings
assert all(warning.category is foghttp.UnclosedClientError for warning in caught)
Loading