Skip to content

Commit 45ba2d2

Browse files
committed
Merge remote-tracking branch 'origin/master' into fix/streamed-log-stop-hang-and-tail-flush
# Conflicts: # tests/unit/test_logging.py
2 parents 4101ef4 + 46b4789 commit 45ba2d2

5 files changed

Lines changed: 70 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
<!-- git-cliff-unreleased-start -->
6+
## 3.0.2 - **not yet released**
7+
8+
### 🐛 Bug Fixes
9+
10+
- Add missing response fields returned by the live API ([#821](https://github.com/apify/apify-client-python/pull/821)) ([e794411](https://github.com/apify/apify-client-python/commit/e794411dd3935cd09941096abc9767f33c4a4cf9)) by [@apify-service-account](https://github.com/apify-service-account)
11+
- Prevent StreamedLog stop() from hanging on a silent stream ([#825](https://github.com/apify/apify-client-python/pull/825)) ([c15cb1b](https://github.com/apify/apify-client-python/commit/c15cb1bb2702120dd6fabe154a4b3d879248aafa)) by [@vdusek](https://github.com/vdusek)
12+
13+
14+
<!-- git-cliff-unreleased-end -->
515
## [3.0.1](https://github.com/apify/apify-client-python/releases/tag/v3.0.1) (2026-05-22)
616

717
### 🐛 Bug Fixes

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "apify_client"
7-
version = "3.0.1"
7+
version = "3.0.2"
88
description = "Apify API client for Python"
99
authors = [{ name = "Apify Technologies s.r.o.", email = "support@apify.com" }]
1010
license = { file = "LICENSE" }

src/apify_client/_streamed_log.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
import re
66
import threading
77
from asyncio import Task
8-
from datetime import UTC, datetime
8+
from datetime import UTC, datetime, timedelta
99
from threading import Thread
10-
from typing import TYPE_CHECKING, Self, cast
10+
from typing import TYPE_CHECKING, ClassVar, Self, cast
1111

1212
from apify_client._docs import docs_group
1313

@@ -90,6 +90,10 @@ class StreamedLog(StreamedLogBase):
9090
call `start` and `stop` manually. Obtain an instance via `RunClient.get_streamed_log`.
9191
"""
9292

93+
# Caps how long `iter_bytes()` can block on a silent stream so `stop()` can unblock within
94+
# this window instead of waiting for the long-polling default.
95+
_read_timeout: ClassVar[timedelta] = timedelta(seconds=30)
96+
9397
def __init__(self, log_client: LogClient, *, to_logger: logging.Logger, from_start: bool = True) -> None:
9498
"""Initialize `StreamedLog`.
9599
@@ -138,17 +142,17 @@ def __exit__(
138142
self.stop()
139143

140144
def _stream_log(self) -> None:
141-
with self._log_client.stream(raw=True) as log_stream:
145+
with self._log_client.stream(raw=True, timeout=self._read_timeout) as log_stream:
142146
if not log_stream:
143147
return
144-
for data in log_stream.iter_bytes():
145-
self._process_new_data(data)
146-
if self._stop_logging:
147-
break
148-
149-
# If the stream is finished, then the last part will be also processed.
150-
self._log_buffer_content(include_last_part=True)
151-
return
148+
try:
149+
for data in log_stream.iter_bytes():
150+
self._process_new_data(data)
151+
if self._stop_logging:
152+
break
153+
finally:
154+
# Flush the last buffered part even if the read timed out or was stopped.
155+
self._log_buffer_content(include_last_part=True)
152156

153157

154158
@docs_group('Other')

tests/unit/test_logging.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from apify_client import ApifyClient, ApifyClientAsync
1616
from apify_client._logging import RedirectLogFormatter
1717
from apify_client._status_message_watcher import StatusMessageWatcherBase
18-
from apify_client._streamed_log import StreamedLogBase
18+
from apify_client._streamed_log import StreamedLog, StreamedLogBase
1919

2020
if TYPE_CHECKING:
2121
from collections.abc import Iterator
@@ -818,3 +818,45 @@ def generate_logs() -> Iterator[bytes]:
818818
messages = [record.message for record in caplog.records]
819819
assert any(_TAIL_FIRST_MESSAGE in m for m in messages), f'First message missing. Got: {messages}'
820820
assert any(_TAIL_SECOND_MESSAGE in m for m in messages), f'Buffered tail dropped on async stop(). Got: {messages}'
821+
822+
823+
def test_streamed_log_sync_stop_does_not_hang_on_silent_stream(
824+
httpserver: HTTPServer,
825+
monkeypatch: pytest.MonkeyPatch,
826+
) -> None:
827+
"""Verify `stop()` returns promptly even when the underlying stream is silent (no chunks)."""
828+
# Shorten the read timeout so the test doesn't wait for the production default.
829+
monkeypatch.setattr(StreamedLog, '_read_timeout', timedelta(seconds=1))
830+
831+
release_server = threading.Event()
832+
833+
def _silent_handler(_request: Request) -> Response:
834+
def generate_logs() -> Iterator[bytes]:
835+
# Yield an empty chunk so werkzeug flushes headers and the client sees a streaming
836+
# response; then block without emitting any log data.
837+
yield b''
838+
release_server.wait(timeout=30)
839+
840+
return Response(response=generate_logs(), status=200, mimetype='application/octet-stream')
841+
842+
httpserver.expect_request(
843+
f'/v2/actor-runs/{_MOCKED_RUN_ID}/log', method='GET', query_string='stream=true&raw=true'
844+
).respond_with_handler(_silent_handler)
845+
_register_run_and_actor_endpoints(httpserver)
846+
847+
api_url = httpserver.url_for('/').removesuffix('/')
848+
run_client = ApifyClient(token='mocked_token', api_url=api_url).run(run_id=_MOCKED_RUN_ID)
849+
streamed_log = run_client.get_streamed_log()
850+
851+
streamed_log.start()
852+
try:
853+
# Give the streaming thread time to start and block inside iter_bytes.
854+
time.sleep(0.3)
855+
856+
# Call stop() from a helper thread so the test cannot hang indefinitely if the fix regresses.
857+
stop_thread = threading.Thread(target=streamed_log.stop)
858+
stop_thread.start()
859+
stop_thread.join(timeout=5)
860+
assert not stop_thread.is_alive(), 'stop() hangs when the underlying stream is silent'
861+
finally:
862+
release_server.set()

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)