Skip to content
Closed
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
14 changes: 12 additions & 2 deletions src/apify_client/_resource_clients/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@
from apify_client.types import Timeout


def _build_abort_body(*, gracefully: bool | None) -> dict[str, bool]:
"""Build the JSON body of the abort request, leaving out the options the caller did not set."""
body: dict[str, bool] = {}
if gracefully is not None:
body['gracefully'] = gracefully
return body


@docs_group('Resource clients')
class RunClient(ResourceClient):
"""Sub-client for managing a specific Actor run.
Expand Down Expand Up @@ -131,7 +139,8 @@ def abort(self, *, gracefully: bool | None = None, timeout: Timeout = 'medium')
response = self._http_client.call(
url=self._build_url('abort'),
method='POST',
params=self._build_params(gracefully=gracefully),
headers={'content-type': 'application/json'},
data=json.dumps(_build_abort_body(gracefully=gracefully)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important: the abort handler reads gracefully only from the query string, so moving it into the JSON body means the API silently ignores it and abort(gracefully=True) always aborts immediately. (apify-core src/api/src/routes/actor_runs/run_abort.ts:28)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important: the abort handler reads gracefully only from the query string and ignores the body, so abort(gracefully=True) now aborts the run immediately instead of gracefully (same in the async client at line 691). (apify-core src/api/src/routes/actor_runs/run_abort.ts:28)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important: the abort endpoint only reads gracefully from the query string, so a flag sent in the body is ignored. Every abort(gracefully=True) call, including the SDK's Actor.abort and child-run cleanup, now does a hard abort instead of a graceful one. (apify-core src/api/src/routes/actor_runs/run_abort.ts:28)

timeout=timeout,
)
result = response_to_dict(response)
Expand Down Expand Up @@ -678,7 +687,8 @@ async def abort(self, *, gracefully: bool | None = None, timeout: Timeout = 'med
response = await self._http_client.call(
url=self._build_url('abort'),
method='POST',
params=self._build_params(gracefully=gracefully),
headers={'content-type': 'application/json'},
data=json.dumps(_build_abort_body(gracefully=gracefully)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important: the async client has the same problem. The API never reads gracefully from the body, so Actor.abort(gracefully=True) in the SDK and the graceful abort of child runs both stop running Actors right away and skip the aborting/persistState events. (apify-sdk-python src/apify/_child_runs.py:267)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important: the async client has the same regression. The API never reads gracefully from the body, so await abort(gracefully=True) silently turns into a forced abort. (apify-core src/api/src/routes/actor_runs/run_abort.ts:28)

timeout=timeout,
)
result = response_to_dict(response)
Expand Down
102 changes: 102 additions & 0 deletions tests/unit/test_run_abort.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from __future__ import annotations

import json
from typing import TYPE_CHECKING

import pytest
from werkzeug import Request, Response

from apify_client import ApifyClient, ApifyClientAsync

if TYPE_CHECKING:
from pytest_httpserver import HTTPServer

pytestmark = pytest.mark.usefixtures('http_client_classes')

_MOCKED_RUN_ID = 'test_run_id'
_ABORT_PATH = f'/v2/actor-runs/{_MOCKED_RUN_ID}/abort'
_RUN_RESPONSE = {
'data': {
'id': _MOCKED_RUN_ID,
'actId': 'test_actor_id',
'userId': 'test_user_id',
'startedAt': '2024-01-01T00:00:00.000Z',
'status': 'ABORTING',
'meta': {'origin': 'API'},
'stats': {},
'options': {'build': 'latest', 'timeoutSecs': 3600, 'memoryMbytes': 1024, 'diskMbytes': 2048},
'buildId': 'test_build_id',
'defaultKeyValueStoreId': 'test_kvs_id',
'defaultDatasetId': 'test_dataset_id',
'defaultRequestQueueId': 'test_rq_id',
'containerUrl': 'https://example.com',
}
}


@pytest.mark.parametrize(
('gracefully', 'expected_body'),
[
pytest.param(None, {}, id='default'),
pytest.param(True, {'gracefully': True}, id='graceful'),
pytest.param(False, {'gracefully': False}, id='forced'),
],
)
def test_run_abort_sends_gracefully_in_body_sync(
httpserver: HTTPServer,
*,
gracefully: bool | None,
expected_body: dict,
) -> None:
"""The graceful flag travels in the JSON body and is left out when the caller did not set it."""
captured_requests: list[Request] = []

def capture_request(request: Request) -> Response:
captured_requests.append(request)
return Response(status=200, response=json.dumps(_RUN_RESPONSE), mimetype='application/json')

httpserver.expect_request(_ABORT_PATH, method='POST').respond_with_handler(capture_request)

api_url = httpserver.url_for('/').removesuffix('/')
client = ApifyClient(token='test_token', api_url=api_url)

run = client.run(_MOCKED_RUN_ID).abort(gracefully=gracefully)

assert run.id == _MOCKED_RUN_ID
assert len(captured_requests) == 1
assert 'gracefully' not in captured_requests[0].args
assert json.loads(captured_requests[0].get_data()) == expected_body


@pytest.mark.parametrize(
('gracefully', 'expected_body'),
[
pytest.param(None, {}, id='default'),
pytest.param(True, {'gracefully': True}, id='graceful'),
pytest.param(False, {'gracefully': False}, id='forced'),
],
)
async def test_run_abort_sends_gracefully_in_body_async(
httpserver: HTTPServer,
*,
gracefully: bool | None,
expected_body: dict,
) -> None:
"""Async variant of `test_run_abort_sends_gracefully_in_body_sync`."""
captured_requests: list[Request] = []

def capture_request(request: Request) -> Response:
captured_requests.append(request)
return Response(status=200, response=json.dumps(_RUN_RESPONSE), mimetype='application/json')

httpserver.expect_request(_ABORT_PATH, method='POST').respond_with_handler(capture_request)

api_url = httpserver.url_for('/').removesuffix('/')
client = ApifyClientAsync(token='test_token', api_url=api_url)

run = await client.run(_MOCKED_RUN_ID).abort(gracefully=gracefully)

assert run.id == _MOCKED_RUN_ID
assert len(captured_requests) == 1
assert 'gracefully' not in captured_requests[0].args
assert json.loads(captured_requests[0].get_data()) == expected_body
Loading