diff --git a/.audit/oberstet_fix_1988.md b/.audit/oberstet_fix_1988.md new file mode 100644 index 000000000..41352660f --- /dev/null +++ b/.audit/oberstet_fix_1988.md @@ -0,0 +1,8 @@ +- [ ] I did **not** use any AI-assistance tools to help create this pull request. +- [x] I **did** use AI-assistance tools to *help* create this pull request. +- [x] I have read, understood and followed the project's AI_POLICY.md when creating code, documentation etc. for this pull request. + +Submitted by: @oberstet +Date: 2026-10-18 +Related issue(s): #1988 +Branch: oberstet:fix_1988 diff --git a/docs/changelog.rst b/docs/changelog.rst index 01e4b9766..e1b67d848 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -25,6 +25,7 @@ Changelog **asyncio** * Fix ``autobahn.asyncio.component.run()``, ``ApplicationRunner.run()``, ``Component.start()`` and the asyncio WebSocket factories failing on CPython 3.14 with ``RuntimeError: There is no current event loop`` when started from plain synchronous code (and the ``DeprecationWarning`` on 3.12 / 3.13): CPython no longer creates the loop implicitly in ``asyncio.get_event_loop()``. A new helper ``autobahn.asyncio.util.get_or_create_event_loop()`` returns the running loop, else the loop set for the thread, else a new one; ``run()`` was first fixed by Lukas Komischke (#1952, #1953), the remaining call sites follow, with a regression test that runs each case in a fresh interpreter (#1965) +* Fix ``ApplicationSession.call()`` on asyncio raising ``AttributeError`` instead of the payload codec's exception when the payload codec fails to encode the call: the error was logged with ``log.failure()``, which only the Twisted logger has. It is now logged with ``log.error()`` and the traceback, on both Twisted and asyncio, and the codec's exception reaches the caller (#1988) **Dependencies** diff --git a/src/autobahn/wamp/protocol.py b/src/autobahn/wamp/protocol.py index 37843eedf..58ffc716c 100644 --- a/src/autobahn/wamp/protocol.py +++ b/src/autobahn/wamp/protocol.py @@ -1947,7 +1947,13 @@ def call(self, procedure: str, *args, **kwargs) -> Any | CallResult: True, procedure, args, kwargs ) except: - self.log.failure() + # log.failure() exists on Twisted only, so log the traceback + # explicitly - this works on both Twisted and asyncio (#1988) + self.log.error( + "payload codec failed to encode call to {procedure}:\n{tb}", + procedure=procedure, + tb=txaio.failure_format_traceback(txaio.create_failure()), + ) raise if encoded_payload: diff --git a/src/autobahn/wamp/test/test_wamp_payload_codec_error.py b/src/autobahn/wamp/test/test_wamp_payload_codec_error.py new file mode 100644 index 000000000..a90bedd69 --- /dev/null +++ b/src/autobahn/wamp/test/test_wamp_payload_codec_error.py @@ -0,0 +1,101 @@ +############################################################################### +# +# The MIT License (MIT) +# +# Copyright (c) typedef int GmbH +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +############################################################################### + +# Issue #1988: ApplicationSession.call() with a payload codec whose encode() +# raises must re-raise the codec's exception, on Twisted AND asyncio. +# Backend-neutral: runs under trial (USE_TWISTED) and pytest (USE_ASYNCIO). +# session.log is deliberately NOT mocked: a Mock has every method, so it +# would hide a logger method that one backend lacks. + +import os +import unittest + +from autobahn.wamp.types import TransportDetails + +USE_TWISTED = bool(os.environ.get("USE_TWISTED", False)) + +if USE_TWISTED: + from autobahn.twisted.wamp import ApplicationSession +else: + from autobahn.asyncio.wamp import ApplicationSession + + +class CodecError(Exception): + pass + + +class FailingPayloadCodec: + def encode(self, is_originating, uri, args=None, kwargs=None): + raise CodecError("encode failed") + + def decode(self, is_originating, uri, encoded_payload): + raise NotImplementedError() + + +class MockTransport: + def __init__(self): + self.messages = [] + self._transport_details = TransportDetails() + + def transport_details(self): + return self._transport_details + + def send(self, msg): + self.messages.append(msg) + + def close(self, *args, **kw): + pass + + +def check_call_reraises_payload_codec_error(test: unittest.TestCase) -> None: + session = ApplicationSession() + session._transport = MockTransport() + session._payload_codec = FailingPayloadCodec() + + with test.assertRaises(CodecError): + session.call("com.example.procedure", 1, 2) + + # nothing went out on the wire + test.assertEqual(session._transport.messages, []) + + +# One class per backend (a base class chosen at import time is a union type, +# which the type checker cannot use as a base). +if USE_TWISTED: + from twisted.trial import unittest as trial_unittest + + class TestPayloadCodecError(trial_unittest.TestCase): + def test_call_reraises_payload_codec_error(self): + check_call_reraises_payload_codec_error(self) + # trial fails a test that logged a Failure; the error is logged + # on purpose (if it is logged as a Failure at all), so accept it + self.flushLoggedErrors(CodecError) + +else: + + class TestPayloadCodecError(unittest.TestCase): + def test_call_reraises_payload_codec_error(self): + check_call_reraises_payload_codec_error(self)