Skip to content

Show how Python activity errors become terminal and reach workflow code #343

Closed

Description

@rmcdaniel

Delivered

Delivered in PR #370, merge 7a820a79d8de20e82e511ecf20bfe3bba6f33883.

Exact-head qualification, artifact deployment and actual Pages publication passed. All eight live Python guides return HTTP 200 with the new terminal-business-errors section, correct canonical/alternate links, matching executable example and working table-of-contents navigation. English, Japanese and Simplified Chinese were inspected at 390px in light/dark themes with CJK fonts; no document overflow.

The published-package fixture receipt preserves all seven original executions: short/qualified matches, subclass/cause counterexamples, wrapped business error, and the exact guide's rejected/accepted inputs. Twelve activities, fourteen attempts, zero repairs; no failed workflow was reclassified.

See the published Python guide. This explains the existing SDK behavior rather than proposing a runtime classification change.

Original report and acceptance

Problem

The Python guide shows how to raise NonRetryableError and list non_retryable_error_types, but leaves out what users need for business errors:

  • Only the outermost exception decides retries. The worker reports its type and class, and the engine compares exact names. Subclasses and causes don't match. So raise RuntimeError(...) from NonRetryableError(...) is retried.
  • NonRetryableError(message, cause=...) keeps the original cause, but the guide never shows wrapping with it.
  • The guide never shows workflow code catching ActivityFailed to handle a terminal business error and continue.

Behavior is correct; the docs are missing.

Acceptance criteria

  • The Python guide states that the outermost exception's short or qualified class name must match non_retryable_error_types exactly, and that subclasses and causes don't count.
  • It shows wrapping a caught exception with NonRetryableError(..., cause=e).
  • It shows a workflow catching ActivityFailed for a business error and continuing.

Activity

  1. added
    documentationImprovements or additions to documentation
    priority:P3Lower-priority product improvement
    on Oct 21, 2026
  2. rmcdaniel commented on Oct 23, 2026

    @rmcdaniel
    MemberAuthor

    Product review: the requested business-error flow already has SDK and runtime primitives (NonRetryableError, exact-name retry policy and ActivityFailed). The missing piece is a correct example and explicit classification rules. I am taking this documentation work, with installed-package qualification first so a product defect is not hidden by prose.

    Next: check exact short/qualified names, subclass and chained-cause controls, and wrapping a cause with NonRetryableError against the published Python SDK. Verify that workflow code catches the terminal business failure and performs its next durable activity. Then update the Python guide and all seven translations with matching examples and source hashes.

  3. rmcdaniel commented on Oct 23, 2026

    @rmcdaniel
    MemberAuthor

    Published-package qualification passes for the requested business-error rules and the exact guide example.

    Tuple: normally installed Python SDK 2.9.0 (wheel SHA-256 1e7547e895485dc8f670bd1b9cc34184b135f249db8da98b16b250bfc6002f73), published PHP Server 2.11.0 at sha256:b29157eb2cf844916debbbb3c01b3257672e0dece778a9c6fa539043a3038f02, Python 3.12 / amd64 / SQLite. No SDK/Server source overlay.

    Case Actual validation attempts Observed workflow outcome
    Exact short class name 1 Catches ActivityFailed, completes another durable activity, then completes normally
    Exact qualified class name 1 Same continued workflow path
    Subclass with only parent name in policy 2 Retries to its recorded two-attempt limit, then follows the caught-failure path
    Outer RuntimeError caused by NonRetryableError 2 Outer exception is reported/retried; inner cause does not stop retries
    Outermost NonRetryableError(..., cause=error) 1 Recorded non-retryable flag is true; workflow catches it and continues
    Exact new guide snippet, amount 0 1 Workflow completes with {"order_id":"order-123","status":"rejected"}
    Exact new guide snippet, amount 7 1 Workflow completes with {"order_id":"order-123","status":"accepted"}

    All seven fresh originals complete within their original 30-second run/execution bounds. The five classification controls each execute a real subsequent website343.record activity, proving the workflow continues after the terminal activity failure. A local assertion also verifies the wrapper retains the original Python __cause__; this is not a cross-SDK cause-object guarantee.

    After the client stopped and the API exited 0 / no OOM, a SELECT-only audit confirms 12 activity executions, 14 activity attempts, zero repairs, exactly one WorkflowCompleted and no WorkflowFailed per original. It verifies the attempt counts above against actual execution/attempt rows, not fixture expectations alone. The database hash is unchanged by the audit. The API container and network are removed; raw DB retirement follows this public receipt.

    Draft implementation is #370. The source examples and prose are translated into all seven other languages; matching code blocks and source hashes pass locally. Full documentation checks and deployment remain next. No missing SDK/runtime feature or new package floor was inferred.

    Original outcomes and reproducible fixture sources

    The isolated Server is bootstrapped with its own SQLite data and alias api in a task-only Docker network. Use the normal registry Python package and run timeout 120 python -u /task/probe.py; then extract the Python block from #370's terminal-business-errors section to example.py and run timeout 60 python -u /task/check_example.py. Both use normal Worker registration/dispatch and Avro payloads. Mount a task directory for the receipts. Stop all writers before the SELECT-only audit. Authentication is disabled only within this isolated fixture; no host port or production namespace is used.

    outcomes.json

    [
      {
        "case": "short",
        "workflow_id": "website343-short-daebb96f",
        "run_id": "01m5kxehcjhtwbhjf93y1fyddf",
        "output": {
          "status": "rejected",
          "continued": {
            "recorded": "short"
          },
          "failure": {
            "exception_type": "BusinessRuleError",
            "exception_class": "__main__.BusinessRuleError",
            "non_retryable": true,
            "activity_type": "website343.validate"
          }
        },
        "expected_validation_attempts": 1
      },
      {
        "case": "qualified",
        "workflow_id": "website343-qualified-d1af1eb7",
        "run_id": "01m5kxeraexert99mxd2587aaf",
        "output": {
          "status": "rejected",
          "continued": {
            "recorded": "qualified"
          },
          "failure": {
            "exception_type": "BusinessRuleError",
            "exception_class": "__main__.BusinessRuleError",
            "non_retryable": true,
            "activity_type": "website343.validate"
          }
        },
        "expected_validation_attempts": 1
      },
      {
        "case": "subclass",
        "workflow_id": "website343-subclass-79d8784d",
        "run_id": "01m5kxey8670a186a075qa0kh5",
        "output": {
          "status": "rejected",
          "continued": {
            "recorded": "subclass"
          },
          "failure": {
            "exception_type": "SpecificBusinessRuleError",
            "exception_class": "__main__.SpecificBusinessRuleError",
            "non_retryable": false,
            "activity_type": "website343.validate"
          }
        },
        "expected_validation_attempts": 2
      },
      {
        "case": "cause",
        "workflow_id": "website343-cause-fd44fcfa",
        "run_id": "01m5kxf5bqnqp60e1d88ckjxvp",
        "output": {
          "status": "rejected",
          "continued": {
            "recorded": "cause"
          },
          "failure": {
            "exception_type": "RuntimeError",
            "exception_class": "builtins.RuntimeError",
            "non_retryable": false,
            "activity_type": "website343.validate"
          }
        },
        "expected_validation_attempts": 2
      },
      {
        "case": "wrapped",
        "workflow_id": "website343-wrapped-f72aed7f",
        "run_id": "01m5kxfch5zydj961y7heq706a",
        "output": {
          "status": "rejected",
          "continued": {
            "recorded": "wrapped"
          },
          "failure": {
            "exception_type": "NonRetryableError",
            "exception_class": "durable_workflow.errors.NonRetryableError",
            "non_retryable": true,
            "activity_type": "website343.validate"
          }
        },
        "expected_validation_attempts": 1
      }
    ]

    example-outcomes.json

    [
      {
        "amount": 0,
        "workflow_id": "website343-example-0-dc6895ab",
        "run_id": "01m5kxk5g5k3b1rbv6h82fmgj1",
        "output": {
          "order_id": "order-123",
          "status": "rejected"
        }
      },
      {
        "amount": 7,
        "workflow_id": "website343-example-7-891717d7",
        "run_id": "01m5kxk8gyxrcwkzc1c4tzby75",
        "output": {
          "order_id": "order-123",
          "status": "accepted"
        }
      }
    ]

    probe.py (SHA-256 d03dcc053b9acca95e77da86803ab47613cee3ae813a90679f5a50a2bad3ce58)

    import asyncio, importlib.metadata, json, sys, uuid
    from pathlib import Path
    from durable_workflow import Client, Worker, workflow, activity, NonRetryableError, ActivityFailed
    
    class BusinessRuleError(Exception): pass
    class SpecificBusinessRuleError(BusinessRuleError): pass
    
    @activity.defn(name='website343.validate')
    async def validate(case):
        if case in ['short','qualified']: raise BusinessRuleError('business refusal')
        if case=='subclass': raise SpecificBusinessRuleError('subclass refusal')
        if case=='cause': raise RuntimeError('outer wrapper') from NonRetryableError('inner marker')
        if case=='wrapped':
            try: raise ValueError('original validation error')
            except ValueError as error:
                wrapped=NonRetryableError('business validation failed',cause=error)
                assert wrapped.__cause__ is error
                raise wrapped
        raise AssertionError(case)
    
    @activity.defn(name='website343.record')
    async def record(case): return {'recorded':case}
    
    @workflow.defn(name='website343.business')
    class BusinessWorkflow:
        def run(self,ctx,case):
            names=['__main__.BusinessRuleError'] if case=='qualified' else ['BusinessRuleError']
            try:
                yield ctx.schedule_activity('website343.validate',[case],retry_policy={'max_attempts':2,'backoff_seconds':[0],'non_retryable_error_types':names})
            except ActivityFailed as error:
                details={'exception_type':error.exception_type,'exception_class':error.exception_class,'non_retryable':error.non_retryable,'activity_type':error.activity_type}
                next_result=yield ctx.schedule_activity('website343.record',[case],retry_policy={'max_attempts':1})
                return {'status':'rejected','continued':next_result,'failure':details}
            raise AssertionError('validation unexpectedly succeeded')
    
    async def main():
        assert importlib.metadata.version('durable-workflow')=='2.9.0'
        result=[]
        async with Client('http://api:8080') as client:
            for case in ['short','qualified','subclass','cause','wrapped']:
                identity='website343-'+case+'-'+uuid.uuid4().hex[:8]
                worker=Worker(client,task_queue=identity,worker_id=identity,workflows=[BusinessWorkflow],activities=[validate,record],poll_timeout=1)
                handle=await client.start_workflow(workflow_type='website343.business',task_queue=identity,workflow_id=identity,input=[case],run_timeout_seconds=30,execution_timeout_seconds=30)
                await worker.run_until(workflow_id=identity,timeout=25,poll_interval=.05)
                output=await handle.result(timeout=2)
                assert output['status']=='rejected' and output['continued']=={'recorded':case},output
                if case=='wrapped':assert output['failure']['non_retryable'] is True,output
                state=await handle.describe()
                result.append({'case':case,'workflow_id':identity,'run_id':state.run_id,'output':output,'expected_validation_attempts':2 if case in ['subclass','cause'] else 1})
                print(json.dumps(result[-1]),flush=True)
        Path('/task/outcomes.json').write_text(json.dumps(result,indent=2)+'\n')
    
    if __name__=='__main__':
        asyncio.run(main())

    example.py (SHA-256 d98a3ea81af4edd10cbb3fa78e4edbf9bc5193a571106385c4cc5932c3946a80)

    from durable_workflow import ActivityFailed, NonRetryableError, activity, workflow
    
    @activity.defn(name="validate_order")
    async def validate_order(order: dict) -> dict:
        try:
            if order.get("amount", 0) <= 0:
                raise ValueError("amount must be positive")
        except ValueError as error:
            raise NonRetryableError("Order validation failed", cause=error) from error
        return order
    
    @workflow.defn(name="place_order")
    class PlaceOrderWorkflow:
        def run(self, ctx, order: dict):
            try:
                yield ctx.schedule_activity("validate_order", [order])
            except ActivityFailed as failure:
                if (
                    failure.activity_type != "validate_order"
                    or failure.exception_type != "NonRetryableError"
                    or not failure.non_retryable
                ):
                    raise
                return {"order_id": order["id"], "status": "rejected"}
            return {"order_id": order["id"], "status": "accepted"}

    check_example.py (SHA-256 6ca971cd31d6597ccc60812db9e0452dee4b6e5675828b46a9966eb2debe0a6c)

    import asyncio,json,uuid
    from pathlib import Path
    from durable_workflow import Client,Worker
    from example import PlaceOrderWorkflow,validate_order
    async def main():
        records=[]
        async with Client('http://api:8080') as client:
            for amount,status in [(0,'rejected'),(7,'accepted')]:
                identity='website343-example-'+str(amount)+'-'+uuid.uuid4().hex[:8]
                handle=await client.start_workflow(workflow_type='place_order',task_queue=identity,workflow_id=identity,input=[{'id':'order-123','amount':amount}],run_timeout_seconds=30,execution_timeout_seconds=30)
                worker=Worker(client,task_queue=identity,worker_id=identity,workflows=[PlaceOrderWorkflow],activities=[validate_order],poll_timeout=1)
                await worker.run_until(workflow_id=identity,timeout=25,poll_interval=.05)
                output=await handle.result(timeout=2)
                assert output=={'order_id':'order-123','status':status},output
                state=await handle.describe()
                record={'amount':amount,'workflow_id':identity,'run_id':state.run_id,'output':output};records.append(record)
                print(json.dumps(record),flush=True)
        Path('/task/example-outcomes.json').write_text(json.dumps(records,indent=2)+'\n')
    if __name__=='__main__':asyncio.run(main())

    audit.py (SHA-256 5f6c7972057d61742e2d327d5accd326af848e7ecc04ff98f1f6e456133efaf7)

    from pathlib import Path
    import sqlite3,json,hashlib
    root=Path(__file__).parent;db=root/'data/database.sqlite'
    before=hashlib.sha256(db.read_bytes()).hexdigest()
    c=sqlite3.connect('file:'+str(db)+'?mode=ro',uri=True);c.row_factory=sqlite3.Row
    rows=json.loads((root/'outcomes.json').read_text());examples=json.loads((root/'example-outcomes.json').read_text());audited=[]
    for original in rows+examples:
     run=c.execute('select * from workflow_runs where id=?',[original['run_id']]).fetchone();assert run['status']=='completed'
     acts=c.execute('select * from activity_executions where workflow_run_id=? order by sequence',[run['id']]).fetchall()
     validation=acts[0];expected=original.get('expected_validation_attempts',1)
     assert validation['attempt_count']==expected,(original,validation['attempt_count'])
     attempts=c.execute('select * from activity_attempts where activity_execution_id=?',[validation['id']]).fetchall();assert len(attempts)==expected
     if 'case' in original:
      assert validation['status']=='failed'
      assert len(acts)==2 and acts[1]['activity_type']=='website343.record' and acts[1]['status']=='completed' and acts[1]['attempt_count']==1
     else:
      assert len(acts)==1
      assert validation['status']==('failed' if original['amount']==0 else 'completed')
     tasks=c.execute('select * from workflow_tasks where workflow_run_id=?',[run['id']]).fetchall();assert sum(t['repair_count'] for t in tasks)==0
     events=[r[0] for r in c.execute('select event_type from workflow_history_events where workflow_run_id=? order by sequence',[run['id']])]
     assert events.count('WorkflowCompleted')==1 and events.count('WorkflowFailed')==0
     assert run['closed_at'].replace('T',' ')[:19]<=run['run_deadline_at'].replace('T',' ')[:19]
     assert run['closed_at'].replace('T',' ')[:19]<=run['execution_deadline_at'].replace('T',' ')[:19]
     case=original['case'] if 'case' in original else 'exact-example-'+str(original['amount'])
     audited.append({'run_id':run['id'],'case':case,'workflow_status':run['status'],'validation_attempts':expected,'activity_count':len(acts),'total_activity_attempts':sum(a['attempt_count'] for a in acts),'repairs':0,'events':events})
    c.close();assert before==hashlib.sha256(db.read_bytes()).hexdigest()
    receipt={'python':'2.9.0','server':'2.11.0','db_sha256':before,'read_only_hash_preserved':True,'originals':audited}
    (root/'audit.json').write_text(json.dumps(receipt,indent=2)+'\n')
    print(json.dumps({'originals':len(audited),'all_completed':True,'validation_counts':[r['validation_attempts'] for r in audited],'activities':sum(x['activity_count'] for x in audited),'activity_attempts':sum(x['total_activity_attempts'] for x in audited),'repairs':0,'read_only_hash_preserved':True}))
  4. rmcdaniel commented on Oct 23, 2026

    @rmcdaniel
    MemberAuthor

    Delivered in PR #370, merge 7a820a79d8de20e82e511ecf20bfe3bba6f33883.

    Exact-head qualification, artifact deployment and actual Pages publication passed. All eight live Python guides return HTTP 200 with the new terminal-business-errors section, correct canonical/alternate links, matching executable example and working table-of-contents navigation. English, Japanese and Simplified Chinese were inspected at 390px in light/dark themes with CJK fonts; no document overflow.

    The published-package fixture receipt preserves all seven original executions: short/qualified matches, subclass/cause counterexamples, wrapped business error, and the exact guide's rejected/accepted inputs. Twelve activities, fourteen attempts, zero repairs; no failed workflow was reclassified.

    See the published Python guide. This explains the existing SDK behavior rather than proposing a runtime classification change.

  5. added
    status:doneDerived from the authoritative closed issue state
    completion:evidence-verifiedAcceptance, fixed version, and required operational evidence are publicly verified
    on Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    completion:evidence-verifiedAcceptance, fixed version, and required operational evidence are publicly verifieddocumentationImprovements or additions to documentationpriority:P3Lower-priority product improvementstatus:doneDerived from the authoritative closed issue state

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions