Skip to content

Commit 4f72633

Browse files
TD-P003: invocable HTTP carrier has no contract coverage for activity-level failure paths (#32)
1 parent 1db8936 commit 4f72633

1 file changed

Lines changed: 128 additions & 1 deletion

File tree

tests/test_invocable.py

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import pytest
1111

1212
from durable_workflow import serializer
13-
from durable_workflow.errors import NonRetryableError
13+
from durable_workflow.errors import ActivityCancelled, NonRetryableError
1414
from durable_workflow.external_task_result import parse_external_task_result
1515
from durable_workflow.invocable import (
1616
InvocableActivityHandler,
@@ -260,3 +260,130 @@ def test_lambda_invocable_activity_handler_rejects_missing_body() -> None:
260260

261261
assert response["statusCode"] == 400
262262
assert json.loads(response["body"])["error"] == "invalid_invocable_request"
263+
264+
265+
# HTTP carrier contract: activity-level failures must return HTTP 200 with a
266+
# valid external-task result envelope, not HTTP 400. HTTP 400 is reserved for
267+
# requests that cannot be parsed into a task envelope at all.
268+
269+
270+
async def test_invocable_http_request_propagates_non_retryable_error_as_200() -> None:
271+
def handler() -> None:
272+
raise NonRetryableError("card rejected")
273+
274+
response = await handle_invocable_http_request(
275+
json.dumps(activity_input()),
276+
{"billing.charge-card": handler},
277+
result_codec=serializer.JSON_CODEC,
278+
)
279+
280+
assert response.status_code == 200
281+
assert response.headers["Content-Type"] == "application/vnd.durable-workflow.external-task-result+json"
282+
result = parse_external_task_result(response.json())
283+
assert result.failed is True
284+
assert result.retryable is False
285+
assert result.failure_kind == "application"
286+
assert result.failure is not None
287+
assert result.failure.message == "card rejected"
288+
289+
290+
async def test_invocable_http_request_propagates_generic_exception_as_200() -> None:
291+
def handler() -> None:
292+
raise RuntimeError("unexpected crash")
293+
294+
response = await handle_invocable_http_request(
295+
json.dumps(activity_input()),
296+
{"billing.charge-card": handler},
297+
result_codec=serializer.JSON_CODEC,
298+
)
299+
300+
assert response.status_code == 200
301+
result = parse_external_task_result(response.json())
302+
assert result.failed is True
303+
assert result.retryable is True
304+
assert result.failure_kind == "application"
305+
assert result.failure is not None
306+
assert "unexpected crash" in result.failure.message
307+
308+
309+
async def test_invocable_http_request_propagates_cancellation_as_200() -> None:
310+
def handler() -> None:
311+
raise ActivityCancelled("cancelled by workflow")
312+
313+
response = await handle_invocable_http_request(
314+
json.dumps(activity_input()),
315+
{"billing.charge-card": handler},
316+
result_codec=serializer.JSON_CODEC,
317+
)
318+
319+
assert response.status_code == 200
320+
result = parse_external_task_result(response.json())
321+
assert result.failed is True
322+
assert result.retryable is False
323+
assert result.cancelled is True
324+
assert result.failure_classification == "cancelled"
325+
326+
327+
async def test_invocable_http_request_propagates_expired_deadline_as_200() -> None:
328+
envelope = activity_input()
329+
envelope["lease"]["expires_at"] = _iso(datetime.now(timezone.utc) - timedelta(seconds=1))
330+
331+
response = await handle_invocable_http_request(
332+
json.dumps(envelope),
333+
{"billing.charge-card": lambda: {"approved": True}},
334+
result_codec=serializer.JSON_CODEC,
335+
)
336+
337+
assert response.status_code == 200
338+
result = parse_external_task_result(response.json())
339+
assert result.failed is True
340+
assert result.retryable is True
341+
assert result.deadline_exceeded is True
342+
343+
344+
async def test_invocable_http_request_propagates_handler_timeout_as_200() -> None:
345+
async def slow_handler() -> None:
346+
await asyncio.sleep(1)
347+
348+
response = await handle_invocable_http_request(
349+
json.dumps(activity_input(expires_in=timedelta(milliseconds=100))),
350+
{"billing.charge-card": slow_handler},
351+
result_codec=serializer.JSON_CODEC,
352+
)
353+
354+
assert response.status_code == 200
355+
result = parse_external_task_result(response.json())
356+
assert result.failed is True
357+
assert result.retryable is True
358+
assert result.deadline_exceeded is True
359+
360+
361+
async def test_invocable_http_request_propagates_unknown_handler_as_200() -> None:
362+
response = await handle_invocable_http_request(
363+
json.dumps(activity_input()),
364+
{},
365+
result_codec=serializer.JSON_CODEC,
366+
)
367+
368+
assert response.status_code == 200
369+
result = parse_external_task_result(response.json())
370+
assert result.failed is True
371+
assert result.retryable is False
372+
assert result.failure_kind == "application"
373+
assert result.failure is not None
374+
assert "no invocable activity handler registered" in result.failure.message
375+
376+
377+
async def test_invocable_http_request_propagates_workflow_task_rejection_as_200() -> None:
378+
response = await handle_invocable_http_request(
379+
json.dumps(load_fixture("workflow-task.v1.json")),
380+
{"billing.invoice.workflow": lambda: {"ignored": True}},
381+
result_codec=serializer.JSON_CODEC,
382+
)
383+
384+
assert response.status_code == 200
385+
result = parse_external_task_result(response.json())
386+
assert result.failed is True
387+
assert result.retryable is False
388+
assert result.failure is not None
389+
assert "only accept activity_task" in result.failure.message

0 commit comments

Comments
 (0)