Skip to content

fix: preserve empty callback error history - #870

Open
zhongkechen wants to merge 7 commits into
mainfrom
codex/fix-862-empty-callback-errors
Open

zhongkechen wants to merge 7 commits into
mainfrom
codex/fix-862-empty-callback-errors

Conversation

@zhongkechen

@zhongkechen zhongkechen commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Issue #, if available

Fixes #862.

Description of changes

An empty callback failure error was retained in live memory but disappeared after
filesystem serialization, while history parsing dropped a present empty payload.
Normalize empty diagnostics at the actual callback completion boundary, preserve
Error.Payload: {} in detailed callback-failure history, and normalize that empty
payload back to absent diagnostics when reconstructing SDK operation state.

The fix preserves meaningful fields, including explicit empty strings and empty
stack-trace lists. Metadata-only history still omits payloads and uses
Truncated: true; detailed timeout history is unchanged. The handler still sees
the existing CallbackExternalError and its default message when diagnostics are
absent. Callback update IDs, input acknowledgment, replay filtering, and OTel
behavior remain outside this PR.

Design

  • Design note (rule 3): not needed; this fixes conversion consistency using the
    existing error/state/history APIs.
  • Simpler options considered (rule 2): relying on filesystem round-trips leaves
    live memory inconsistent. Normalizing every history payload would lose the
    present-empty distinction. Normalize at the callback completion and
    history-to-operation boundaries, and keep the empty payload in the detailed
    history projection. Reuse one private conversion function for callback, step,
    context, and chained-invoke history instead of duplicating its condition.
  • Root cause (rule 5): history payload presence and SDK diagnostic presence were
    treated as the same representation; the completion boundary relied on later
    serialization to normalize empty errors.
  • Net change: +20 source lines; zero public API members added or removed.

Assumptions

Decisions not covered by the issue or the design note

  • The live regression captures the operation returned by the real completion
    method before the executor persists it, so filesystem decoding cannot hide a
    missing boundary normalization.
  • Retain the existing graded runtime callback exception; the external error's
    type is checked on operation details, where the SDK currently preserves it.
  • Round-trip assertions focus on callback failure error payloads; unrelated
    execution-history serialization fields are outside this fix.

Validation

  • Python 3.13.17, Hatch 1.16.5.
  • Focused baseline on d7ae955: 15 failed, 76 passed. The failures include all
    four live omitted/empty error cases across memory/filesystem stores; rich-error
    controls pass unchanged. With this fix: 91 passed.
  • Tests cover omitted/null/empty/meaningful payloads, truncation flags, direct and
    decoded history for four operation types, actual public-runner completion,
    pre-persistence state, reload, and a saved step executing once across resume.
  • hatch run dev-testing:typecheck: passed (137 source files).
  • hatch run dev-core:test -q --tb=short: 2,124 passed.
  • hatch run dev-core:typecheck: passed (96 source files).
  • Both package hatch fmt --check runs and git diff --check: passed.
  • hatch run dev-testing:test -q --tb=short: 1,839 passed (two existing
    deprecation warnings), with loopback access for HTTP integration tests.
  • hatch run dev-testing:js-examples --js-dir <pinned-checkout> --no-build:
    123 test files passed, 150 tests passed, zero retries; four selected files
    have no cloud tests. Used JS SDK commit
    3deb8389531d35642e2d4657a69575522dc7a7ea, the same source validated for [Testing]: Record callback terminal completions as updated operations #860/[Testing]: Acknowledge invocation input without losing pending updates #861.
  • No existing test expectations changed.

Checklist

  • Every new branch has a test that fails without it (rule 4).
  • The first commit adds regression tests; baseline/fixed results are above (rule 5).
  • No existing test expectations changed (rule 5).

By submitting this pull request, I confirm that you can use, modify, copy, and
redistribute this contribution, under the terms of your choice.

@zhongkechen
zhongkechen marked this pull request as ready for review October 28, 2026 19:08
@zhongkechen
zhongkechen deployed to ai-pr-review-runtime October 28, 2026 19:08 — with GitHub Actions Active
@zhongkechen
zhongkechen deployed to ai-pr-review-runtime October 28, 2026 19:09 — with GitHub Actions Active
@github-actions

This comment has been minimized.

@zhongkechen zhongkechen changed the title fix: preserve empty callback error history fix: preserve empty errors in SDK deserialization Oct 28, 2026
Preserve a present empty ErrorObject in callback, step, context, and
chained-invoke details by checking presence instead of truthiness.
Keep the matching history payload decoder fix and remove the testing
library's completion/reconstruction normalization and synthetic payload.

Add core decoding and round-trip regressions, and require live callback
errors to survive memory and filesystem persistence with the existing
default CallbackExternalError message. The focused regressions fail
25 cases on the previous PR revision and pass all 131 with this fix.
@zhongkechen
zhongkechen deployed to ai-pr-review-runtime October 28, 2026 19:31 — with GitHub Actions Active
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@yaythomas

Copy link
Copy Markdown
Contributor

thanks! preserving Error: {} through decoding and persistence is intent here, and it matches the JavaScript SDK and the service.

The preserved empty object is currently treated as if it contains error details. As a result, plugins receive an empty ErrorObject, and step, context, and invoke exceptions have message=None. Passing that empty object to plugins does not match JavaScript. It causes OTel to record an exception with no message or type. Using that empty object to create an exception also gives the exception message=None.

Can we keep the current decoder changes, but treat an ErrorObject with no fields like a missing error when building plugin information and exception messages? Concretely, this should only require plugin.py::_extract_error() and state.py::CheckpointedResult.raise_operation_error() to treat not error.to_dict() the same as error is None; the decoder changes can stay unchanged?

Revert 58567dc to restore the original
testing-library solution. Normalize empty callback and reconstructed
history errors to absent SDK diagnostics while preserving empty payloads
in detailed callback history.

Restore the original regression expectations and README. Keep the later
main-branch merges and unrelated fixes intact.
@zhongkechen zhongkechen changed the title fix: preserve empty errors in SDK deserialization fix: preserve empty callback error history Oct 28, 2026
@zhongkechen

Copy link
Copy Markdown
Contributor Author

thanks! preserving Error: {} through decoding and persistence is intent here, and it matches the JavaScript SDK and the service.

The preserved empty object is currently treated as if it contains error details. As a result, plugins receive an empty ErrorObject, and step, context, and invoke exceptions have message=None. Passing that empty object to plugins does not match JavaScript. It causes OTel to record an exception with no message or type. Using that empty object to create an exception also gives the exception message=None.

Can we keep the current decoder changes, but treat an ErrorObject with no fields like a missing error when building plugin information and exception messages? Concretely, this should only require plugin.py::_extract_error() and state.py::CheckpointedResult.raise_operation_error() to treat not error.to_dict() the same as error is None; the decoder changes can stay unchanged?

Thanks for the clarification. This PR will focus on the OTel behavior fixes: marking failed operations as ERROR even when diagnostic details are absent, and avoiding empty exception events.

I’ll retain the existing core SDK error-decoding behavior here and leave changes to those semantics for future PRs.

@zhongkechen
zhongkechen deployed to ai-pr-review-runtime October 28, 2026 22:56 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown
Contributor

Codex AI review

No actionable findings across the five changed files or CONTRIBUTING.md rules 1–9. Tests were inspected but not executed. The PR explicitly leaves deployed-service parity for empty callback-failure payloads unverified.

Reviewed commit 1eb341b1054af2a9a25ad6907ca64d522b909bdd. Workflow run

assert reconstructed.callback_details.error == (
expected_error if include_data else None
)
assert history.to_dict() == wire

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.

wire was built from history.to_dict() a few lines up, so this can't fail. Drop it, or snapshot wire before decoding/reconstructing if the goal is to show those steps don't mutate history.

assert history.to_dict() == wire

assert live.callback_details is not None
assert live.callback_details.error == expected_error

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.

Nit: this repeats the loop assertion above (live is already in that tuple).

expected_wire = history.to_dict()
if decode_wire:
history = GetDurableExecutionHistoryResponse.from_dict(expected_wire)
assert history.to_dict() == expected_wire

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.

With decode_wire=False, this compares history.to_dict() with itself, so it can't fail. Same for line 146.

if decode_wire:
history = GetDurableExecutionHistoryResponse.from_dict(expected_wire)
assert history.to_dict() == expected_wire
expected_error = error if payload else None

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.

The previous revision also round-tripped the reconstructed operation through Operation.from_json_dict. That was the only check that a reconstructed op with e.g. {"ErrorMessage": ""} or {"StackTrace": []} survives persistence unchanged. Worth keeping, at least for the non-empty payloads?

)
result = DurableFunctionTestResult.from_execution_history(execution, history)
result_operation = result.get_operation_by_name("failed-operation")
assert isinstance(result_operation, (ContextOperation, InvokeOperation))

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.

StepOperation and CallbackOperation both subclass ContextOperation, so this passes even if an event maps to the wrong wrapper. Could we add the expected wrapper class to the parametrize tuple and assert the exact type?

@SilanHe

SilanHe commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

thanks! preserving Error: {} through decoding and persistence is intent here, and it matches the JavaScript SDK and the service.

The preserved empty object is currently treated as if it contains error details. As a result, plugins receive an empty ErrorObject, and step, context, and invoke exceptions have message=None. Passing that empty object to plugins does not match JavaScript. It causes OTel to record an exception with no message or type. Using that empty object to create an exception also gives the exception message=None.

Can we keep the current decoder changes, but treat an ErrorObject with no fields like a missing error when building plugin information and exception messages? Concretely, this should only require plugin.py::_extract_error() and state.py::CheckpointedResult.raise_operation_error() to treat not error.to_dict() the same as error is None; the decoder changes can stay unchanged?

Thanks for the clarification. This PR will focus on the OTel behavior fixes: marking failed operations as ERROR even when diagnostic details are absent, and avoiding empty exception events.

I’ll retain the existing core SDK error-decoding behavior here and leave changes to those semantics for future PRs.

Do we not need the recommended plugin.py::_extract_error() and state.py::CheckpointedResult.raise_operation_error() changes at all?

This branch was successfully deployed

1 active deployment
ai-pr-review-runtime — 1eb341b1 Deployed Oct 28, 2026 by zhongkechen via ai-pr-review / Codex review / Generate Codex review #2617
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Testing]: Preserve empty callback error history without changing runtime errors

3 participants