From 8afc3ad9a10e195ec43c5f5cbc94f7660681dfaf Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Thu, 11 Jun 2026 15:05:30 +0200 Subject: [PATCH 1/2] fix: add idempotency_key, ignore_ssl_errors and do_not_retry to WebhookRepresentation --- src/apify_client/_models.py | 12 ++++++++++++ src/apify_client/_typeddicts.py | 24 ++++++++++++++++++++++++ src/apify_client/_utils.py | 3 +++ tests/unit/test_utils.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/src/apify_client/_models.py b/src/apify_client/_models.py index 19f5cf6d..57b1db8e 100644 --- a/src/apify_client/_models.py +++ b/src/apify_client/_models.py @@ -3766,6 +3766,18 @@ class WebhookRepresentation(BaseModel): """ Optional template for the HTTP headers sent by the webhook. """ + idempotency_key: Annotated[str | None, Field(alias='idempotencyKey', examples=['fdSJmdP3nfs7sfk3y'])] = None + """ + Optional key that prevents creating duplicate webhooks, e.g. when the run-starting request is retried. + """ + ignore_ssl_errors: Annotated[bool | None, Field(alias='ignoreSslErrors', examples=[False])] = None + """ + Optional flag to ignore SSL errors when the webhook sends the request. + """ + do_not_retry: Annotated[bool | None, Field(alias='doNotRetry', examples=[False])] = None + """ + Optional flag to skip retrying the webhook request on failure. + """ @docs_group('Models') diff --git a/src/apify_client/_typeddicts.py b/src/apify_client/_typeddicts.py index fa430ab7..e3e3bbb1 100644 --- a/src/apify_client/_typeddicts.py +++ b/src/apify_client/_typeddicts.py @@ -336,6 +336,18 @@ class WebhookRepresentationDict(TypedDict): """ Optional template for the HTTP headers sent by the webhook. """ + idempotency_key: NotRequired[str | None] + """ + Optional key that prevents creating duplicate webhooks, e.g. when the run-starting request is retried. + """ + ignore_ssl_errors: NotRequired[bool | None] + """ + Optional flag to ignore SSL errors when the webhook sends the request. + """ + do_not_retry: NotRequired[bool | None] + """ + Optional flag to skip retrying the webhook request on failure. + """ @docs_group('Typed dicts') @@ -374,3 +386,15 @@ class WebhookRepresentationCamelDict(TypedDict): """ Optional template for the HTTP headers sent by the webhook. """ + idempotencyKey: NotRequired[str | None] + """ + Optional key that prevents creating duplicate webhooks, e.g. when the run-starting request is retried. + """ + ignoreSslErrors: NotRequired[bool | None] + """ + Optional flag to ignore SSL errors when the webhook sends the request. + """ + doNotRetry: NotRequired[bool | None] + """ + Optional flag to skip retrying the webhook request on failure. + """ diff --git a/src/apify_client/_utils.py b/src/apify_client/_utils.py index e488b5be..9d8e128e 100644 --- a/src/apify_client/_utils.py +++ b/src/apify_client/_utils.py @@ -307,6 +307,9 @@ def encode_webhooks_to_base64(webhooks: WebhooksList | None) -> str | None: request_url=webhook.request_url, payload_template=webhook.payload_template, headers_template=webhook.headers_template, + idempotency_key=webhook.idempotency_key, + ignore_ssl_errors=webhook.ignore_ssl_errors, + do_not_retry=webhook.do_not_retry, ) ) else: diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 7ecf037c..0fcfe08c 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,6 +1,8 @@ from __future__ import annotations import io +import json +from base64 import b64decode from datetime import timedelta from http import HTTPStatus from typing import TYPE_CHECKING @@ -58,6 +60,33 @@ def test_encode_webhooks_to_base64() -> None: ) +def test_encode_webhooks_to_base64_keeps_adhoc_fields() -> None: + """Test that the idempotency key and the SSL/retry flags survive the projection onto `WebhookRepresentation`.""" + result = encode_webhooks_to_base64( + [ + WebhookCreate( + event_types=['ACTOR.RUN.SUCCEEDED'], + condition=WebhookCondition(), + request_url='https://example.com/run-succeeded', + idempotency_key='some-key', + ignore_ssl_errors=True, + do_not_retry=True, + ), + ] + ) + + assert result is not None + assert json.loads(b64decode(result)) == [ + { + 'eventTypes': ['ACTOR.RUN.SUCCEEDED'], + 'requestUrl': 'https://example.com/run-succeeded', + 'idempotencyKey': 'some-key', + 'ignoreSslErrors': True, + 'doNotRetry': True, + } + ] + + def test_encode_webhooks_to_base64_from_dicts() -> None: """Test that encode_webhooks_to_base64 accepts plain dicts typed as WebhookRepresentationDict.""" webhooks: list[WebhookRepresentationDict] = [ From 43ae378cb193f873bdfc666ef6ae89dd74d61007 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 16 Jun 2026 08:30:45 +0200 Subject: [PATCH 2/2] refactor: project ad-hoc webhooks onto WebhookRepresentation by declared fields --- src/apify_client/_utils.py | 29 +++++++----------- tests/unit/test_utils.py | 60 +++++++++++++++++++++++++++++--------- 2 files changed, 56 insertions(+), 33 deletions(-) diff --git a/src/apify_client/_utils.py b/src/apify_client/_utils.py index 9d8e128e..92ef5c63 100644 --- a/src/apify_client/_utils.py +++ b/src/apify_client/_utils.py @@ -288,34 +288,25 @@ def encode_webhooks_to_base64(webhooks: WebhooksList | None) -> str | None: Returns `None` for `None` or an empty list, so the query parameter is omitted. - See `WebhooksList` for the accepted shapes. `WebhookRepresentation` instances are used as-is; `WebhookCreate` - instances are projected onto the `WebhookRepresentation` fields, dropping persistent-only fields like `condition`. - Dict shapes are validated into `WebhookRepresentation` and only fields it declares are kept. + See `WebhooksList` for the accepted shapes. `WebhookRepresentation` instances are used as-is. `WebhookCreate` + instances and dict shapes are projected onto the fields `WebhookRepresentation` declares, dropping anything else + (e.g. persistent-only fields like `condition`). Filtering by the declared field names and aliases means new + ad-hoc fields added to `WebhookRepresentation` flow through automatically, without touching this function. """ if not webhooks: return None representations = list[WebhookRepresentation]() + allowed = _webhook_representation_keys() for webhook in webhooks: if isinstance(webhook, WebhookRepresentation): representations.append(webhook) - elif isinstance(webhook, WebhookCreate): - representations.append( - WebhookRepresentation( - event_types=webhook.event_types, - request_url=webhook.request_url, - payload_template=webhook.payload_template, - headers_template=webhook.headers_template, - idempotency_key=webhook.idempotency_key, - ignore_ssl_errors=webhook.ignore_ssl_errors, - do_not_retry=webhook.do_not_retry, - ) - ) - else: - allowed = _webhook_representation_keys() - filtered = {k: v for k, v in webhook.items() if k in allowed} - representations.append(WebhookRepresentation.model_validate(filtered)) + continue + + data = webhook.model_dump(by_alias=True) if isinstance(webhook, WebhookCreate) else dict(webhook) + filtered = {key: value for key, value in data.items() if key in allowed} + representations.append(WebhookRepresentation.model_validate(filtered)) data = [r.model_dump(by_alias=True, exclude_none=True) for r in representations] json_string = json.dumps(data).encode(encoding='utf-8') diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 0fcfe08c..90e918af 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -29,6 +29,7 @@ if TYPE_CHECKING: from apify_client._typeddicts import WebhookRepresentationDict + from apify_client.types import WebhooksList def test_to_safe_id() -> None: @@ -60,20 +61,51 @@ def test_encode_webhooks_to_base64() -> None: ) -def test_encode_webhooks_to_base64_keeps_adhoc_fields() -> None: - """Test that the idempotency key and the SSL/retry flags survive the projection onto `WebhookRepresentation`.""" - result = encode_webhooks_to_base64( - [ - WebhookCreate( - event_types=['ACTOR.RUN.SUCCEEDED'], - condition=WebhookCondition(), - request_url='https://example.com/run-succeeded', - idempotency_key='some-key', - ignore_ssl_errors=True, - do_not_retry=True, - ), - ] - ) +@pytest.mark.parametrize( + 'webhooks', + [ + pytest.param( + [ + WebhookCreate( + event_types=['ACTOR.RUN.SUCCEEDED'], + condition=WebhookCondition(), + request_url='https://example.com/run-succeeded', + idempotency_key='some-key', + ignore_ssl_errors=True, + do_not_retry=True, + ), + ], + id='webhook-create-model', + ), + pytest.param( + [ + { + 'event_types': ['ACTOR.RUN.SUCCEEDED'], + 'request_url': 'https://example.com/run-succeeded', + 'idempotency_key': 'some-key', + 'ignore_ssl_errors': True, + 'do_not_retry': True, + }, + ], + id='snake-case-dict', + ), + pytest.param( + [ + { + 'eventTypes': ['ACTOR.RUN.SUCCEEDED'], + 'requestUrl': 'https://example.com/run-succeeded', + 'idempotencyKey': 'some-key', + 'ignoreSslErrors': True, + 'doNotRetry': True, + }, + ], + id='camel-case-dict', + ), + ], +) +def test_encode_webhooks_to_base64_keeps_adhoc_fields(webhooks: WebhooksList) -> None: + """Test that the idempotency key and the SSL/retry flags survive the projection for every accepted shape.""" + result = encode_webhooks_to_base64(webhooks) assert result is not None assert json.loads(b64decode(result)) == [