From 5d37c9d852921839334b462c8e093d2f30ac7d3b Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 11 Jun 2026 14:48:53 +0200 Subject: [PATCH 1/2] fix: warn when ad-hoc webhooks drop unsupported Webhook fields --- src/apify/_webhook.py | 30 +++++++++++++++++++---- tests/unit/actor/test_actor_helpers.py | 33 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/apify/_webhook.py b/src/apify/_webhook.py index 129385c9..6e7be23d 100644 --- a/src/apify/_webhook.py +++ b/src/apify/_webhook.py @@ -7,6 +7,7 @@ from crawlee._utils.urls import validate_http_url from apify._utils import docs_group +from apify.log import logger if TYPE_CHECKING: from apify_client._literals import WebhookEventType @@ -19,6 +20,9 @@ class Webhook: The same instance can be passed as an ad-hoc webhook to `Actor.start()` / `Actor.call()` or as a persistent webhook to `Actor.add_webhook()` (the `condition.actor_run_id` is set automatically to the current run). + + Ad-hoc webhooks support only `event_types`, `request_url`, `payload_template` and `headers_template`; the + remaining fields apply only to `Actor.add_webhook()` and are ignored (with a warning) otherwise. """ event_types: list[WebhookEventType] @@ -34,13 +38,13 @@ class Webhook: """Template for the HTTP headers sent by the webhook.""" idempotency_key: str | None = None - """Key that prevents creating duplicate webhooks.""" + """Key that prevents creating duplicate webhooks. Only applies to `Actor.add_webhook()`.""" ignore_ssl_errors: bool | None = None - """Whether to ignore SSL errors when sending the request.""" + """Whether to ignore SSL errors when sending the request. Only applies to `Actor.add_webhook()`.""" do_not_retry: bool | None = None - """Whether to skip retrying the request on failure.""" + """Whether to skip retrying the request on failure. Only applies to `Actor.add_webhook()`.""" def __post_init__(self) -> None: # Fail fast on a malformed URL at construction time instead of deferring the error to the API call. @@ -48,9 +52,27 @@ def __post_init__(self) -> None: def to_client_representations(webhooks: list[Webhook] | None) -> list[WebhookRepresentation] | None: - """Project SDK webhooks to the minimal ad-hoc representation accepted by the client's `start()` / `call()`.""" + """Project SDK webhooks to the minimal ad-hoc representation accepted by the client's `start()` / `call()`. + + Fields not supported by ad-hoc webhooks (`idempotency_key`, `ignore_ssl_errors`, `do_not_retry`) are dropped + with a warning. + """ if not webhooks: return None + + for webhook in webhooks: + dropped = [ + field + for field in ('idempotency_key', 'ignore_ssl_errors', 'do_not_retry') + if getattr(webhook, field) is not None + ] + if dropped: + fields = ', '.join(f'`{field}`' for field in dropped) + logger.warning( + f'Ad-hoc webhooks do not support {fields}; the field(s) will be ignored. ' + f'Use `Actor.add_webhook()` to create a webhook with them.' + ) + return [ WebhookRepresentation( event_types=w.event_types, diff --git a/tests/unit/actor/test_actor_helpers.py b/tests/unit/actor/test_actor_helpers.py index 633aa028..bb1abee2 100644 --- a/tests/unit/actor/test_actor_helpers.py +++ b/tests/unit/actor/test_actor_helpers.py @@ -251,6 +251,39 @@ async def test_remote_method_with_webhooks( assert kwargs['webhooks'] is not None +@pytest.mark.parametrize(('client_resource', 'client_method', 'actor_method_name', 'entity_id'), _ACTOR_REMOTE_METHODS) +async def test_remote_method_warns_on_unsupported_webhook_fields( + apify_client_async_patcher: ApifyClientAsyncPatcher, + fake_actor_run: Run, + client_resource: str, + client_method: str, + actor_method_name: str, + entity_id: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that start/call/call_task warn about `Webhook` fields not supported by ad-hoc webhooks.""" + apify_client_async_patcher.patch(client_resource, client_method, return_value=fake_actor_run) + caplog.set_level('WARNING') + + async with Actor: + actor_method = getattr(Actor, actor_method_name) + await actor_method( + entity_id, + webhooks=[ + Webhook( + event_types=['ACTOR.RUN.SUCCEEDED'], + request_url='https://example.com', + idempotency_key='some-key', + do_not_retry=True, + ) + ], + ) + + matching = [record for record in caplog.records if 'Ad-hoc webhooks do not support' in record.message] + assert len(matching) == 1 + assert '`idempotency_key`, `do_not_retry`' in matching[0].message + + @pytest.mark.parametrize(('client_resource', 'client_method', 'actor_method_name', 'entity_id'), _ACTOR_REMOTE_METHODS) async def test_remote_method_with_timedelta_timeout( apify_client_async_patcher: ApifyClientAsyncPatcher, From d4c544ebcc85d58e042f5cc3beb722fc7d23ac33 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 11 Jun 2026 15:08:59 +0200 Subject: [PATCH 2/2] fix: forward idempotency_key, ignore_ssl_errors and do_not_retry in ad-hoc webhooks --- src/apify/_webhook.py | 33 ++++++-------------------- tests/unit/actor/test_actor_helpers.py | 25 +++++++++++++------ 2 files changed, 25 insertions(+), 33 deletions(-) diff --git a/src/apify/_webhook.py b/src/apify/_webhook.py index 6e7be23d..2bc2dc99 100644 --- a/src/apify/_webhook.py +++ b/src/apify/_webhook.py @@ -7,7 +7,6 @@ from crawlee._utils.urls import validate_http_url from apify._utils import docs_group -from apify.log import logger if TYPE_CHECKING: from apify_client._literals import WebhookEventType @@ -20,9 +19,6 @@ class Webhook: The same instance can be passed as an ad-hoc webhook to `Actor.start()` / `Actor.call()` or as a persistent webhook to `Actor.add_webhook()` (the `condition.actor_run_id` is set automatically to the current run). - - Ad-hoc webhooks support only `event_types`, `request_url`, `payload_template` and `headers_template`; the - remaining fields apply only to `Actor.add_webhook()` and are ignored (with a warning) otherwise. """ event_types: list[WebhookEventType] @@ -38,13 +34,13 @@ class Webhook: """Template for the HTTP headers sent by the webhook.""" idempotency_key: str | None = None - """Key that prevents creating duplicate webhooks. Only applies to `Actor.add_webhook()`.""" + """Key that prevents creating duplicate webhooks.""" ignore_ssl_errors: bool | None = None - """Whether to ignore SSL errors when sending the request. Only applies to `Actor.add_webhook()`.""" + """Whether to ignore SSL errors when sending the request.""" do_not_retry: bool | None = None - """Whether to skip retrying the request on failure. Only applies to `Actor.add_webhook()`.""" + """Whether to skip retrying the request on failure.""" def __post_init__(self) -> None: # Fail fast on a malformed URL at construction time instead of deferring the error to the API call. @@ -52,33 +48,18 @@ def __post_init__(self) -> None: def to_client_representations(webhooks: list[Webhook] | None) -> list[WebhookRepresentation] | None: - """Project SDK webhooks to the minimal ad-hoc representation accepted by the client's `start()` / `call()`. - - Fields not supported by ad-hoc webhooks (`idempotency_key`, `ignore_ssl_errors`, `do_not_retry`) are dropped - with a warning. - """ + """Convert SDK webhooks to the ad-hoc representation accepted by the client's `start()` / `call()`.""" if not webhooks: return None - - for webhook in webhooks: - dropped = [ - field - for field in ('idempotency_key', 'ignore_ssl_errors', 'do_not_retry') - if getattr(webhook, field) is not None - ] - if dropped: - fields = ', '.join(f'`{field}`' for field in dropped) - logger.warning( - f'Ad-hoc webhooks do not support {fields}; the field(s) will be ignored. ' - f'Use `Actor.add_webhook()` to create a webhook with them.' - ) - return [ WebhookRepresentation( event_types=w.event_types, request_url=w.request_url, payload_template=w.payload_template, headers_template=w.headers_template, + idempotency_key=w.idempotency_key, + ignore_ssl_errors=w.ignore_ssl_errors, + do_not_retry=w.do_not_retry, ) for w in webhooks ] diff --git a/tests/unit/actor/test_actor_helpers.py b/tests/unit/actor/test_actor_helpers.py index bb1abee2..eaec6178 100644 --- a/tests/unit/actor/test_actor_helpers.py +++ b/tests/unit/actor/test_actor_helpers.py @@ -252,18 +252,16 @@ async def test_remote_method_with_webhooks( @pytest.mark.parametrize(('client_resource', 'client_method', 'actor_method_name', 'entity_id'), _ACTOR_REMOTE_METHODS) -async def test_remote_method_warns_on_unsupported_webhook_fields( +async def test_remote_method_forwards_all_webhook_fields( apify_client_async_patcher: ApifyClientAsyncPatcher, fake_actor_run: Run, client_resource: str, client_method: str, actor_method_name: str, entity_id: str, - caplog: pytest.LogCaptureFixture, ) -> None: - """Test that start/call/call_task warn about `Webhook` fields not supported by ad-hoc webhooks.""" + """Test that start/call/call_task forward all `Webhook` fields to the client representation.""" apify_client_async_patcher.patch(client_resource, client_method, return_value=fake_actor_run) - caplog.set_level('WARNING') async with Actor: actor_method = getattr(Actor, actor_method_name) @@ -273,15 +271,28 @@ async def test_remote_method_warns_on_unsupported_webhook_fields( Webhook( event_types=['ACTOR.RUN.SUCCEEDED'], request_url='https://example.com', + payload_template='{"hello": "world"}', + headers_template='{"Authorization": "Bearer ..."}', idempotency_key='some-key', + ignore_ssl_errors=True, do_not_retry=True, ) ], ) - matching = [record for record in caplog.records if 'Ad-hoc webhooks do not support' in record.message] - assert len(matching) == 1 - assert '`idempotency_key`, `do_not_retry`' in matching[0].message + calls = apify_client_async_patcher.calls[client_resource][client_method] + assert len(calls) == 1 + _, kwargs = calls[0][0], calls[0][1] + (representation,) = kwargs['webhooks'] + assert representation.model_dump(by_alias=True, exclude_none=True) == { + 'eventTypes': ['ACTOR.RUN.SUCCEEDED'], + 'requestUrl': 'https://example.com', + 'payloadTemplate': '{"hello": "world"}', + 'headersTemplate': '{"Authorization": "Bearer ..."}', + 'idempotencyKey': 'some-key', + 'ignoreSslErrors': True, + 'doNotRetry': True, + } @pytest.mark.parametrize(('client_resource', 'client_method', 'actor_method_name', 'entity_id'), _ACTOR_REMOTE_METHODS)