Skip to content

Conversation

@ttypic
Copy link
Contributor

@ttypic ttypic commented Jan 14, 2026

  • Added update_message, delete_message, and append_message methods to enable respective operations via the API.
  • Implemented MessageOperation, PublishResult, and UpdateDeleteResult classes for handling operation metadata and results.
  • Bumped api_version to 5

Summary by CodeRabbit

  • New Features

    • Message lifecycle: update, delete, and append operations for published messages.
    • Retrieve individual messages by serial and view full message version history.
    • Message payloads now include action, serial, and per-version metadata; publish responses can return serials.
  • Updates

    • Public API version bumped to v5.
  • Tests

    • New integration and unit tests covering mutable message workflows, encryption, params, versioning, and serialization.

✏️ Tip: You can customize this high-level summary in your review settings.

- Introduced `MessageVersion` class to handle metadata related to message versions, such as serial, timestamp, client_id, description, and metadata.
- Updated `Message` class to include a `version` property and support serialization/deserialization of message versions.
@coderabbitai
Copy link

coderabbitai bot commented Jan 14, 2026

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 49.32% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main changes: REST mutable message support (update/delete/append operations) and protocol v5 API version bump.
Linked Issues check ✅ Passed The PR implements all key requirements: update_message, delete_message, append_message methods; MessageOperation, PublishResult, UpdateDeleteResult classes; and API version bump to 5 AIT-196.
Out of Scope Changes check ✅ Passed All changes align with the PR objectives. Minor test adjustments (removing response captures) are necessary refactoring to accommodate new return types from the message operations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch AIT-196/rest-edits-deletes

🧹 Recent nitpick comments
ably/rest/channel.py (2)

113-123: Consider logging or narrowing the exception catch in publish_messages.

The broad except Exception block silently swallows any parsing errors and returns an empty PublishResult. While this maintains backward compatibility, it could mask unexpected issues. Consider logging the exception at debug level or narrowing to specific expected exceptions.

♻️ Proposed improvement
         try:
             result_data = response.to_native()
             if result_data and isinstance(result_data, dict):
                 return PublishResult.from_dict(result_data)
             return PublishResult()
-        except Exception:
+        except Exception as e:
             # If response parsing fails, return empty PublishResult for backwards compatibility
+            log.debug("Failed to parse publish response: %s", e)
             return PublishResult()

220-227: Same suggestion: consider logging parsing failures in _send_update.

Similar to publish_messages, the broad exception handler here could benefit from debug logging.

♻️ Proposed improvement
         try:
             result_data = response.to_native()
             if result_data and isinstance(result_data, dict):
                 return UpdateDeleteResult.from_dict(result_data)
             return UpdateDeleteResult()
-        except Exception:
+        except Exception as e:
+            log.debug("Failed to parse update/delete response: %s", e)
             return UpdateDeleteResult()
test/ably/rest/restchannelmutablemessages_test.py (1)

240-244: Version order assumptions may cause test brittleness.

The test assumes versions are returned in a specific order (index 0, 1, 2). If the API returns versions in a different order (e.g., newest first), this could fail. Consider sorting or searching by serial rather than assuming positional order.

Verify the expected order of versions returned by get_message_versions - is it chronological (oldest first) or reverse chronological (newest first)?


📜 Recent review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a63e968 and 8ff2552.

📒 Files selected for processing (10)
  • ably/__init__.py
  • ably/rest/channel.py
  • ably/types/message.py
  • ably/types/operations.py
  • test/ably/rest/restchannelmutablemessages_test.py
  • test/ably/rest/restchannelpublish_test.py
  • test/ably/rest/resthttp_test.py
  • test/ably/rest/restrequest_test.py
  • test/assets/testAppSpec.json
  • test/unit/mutable_message_test.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • ably/types/operations.py
  • test/ably/rest/restrequest_test.py
  • test/unit/mutable_message_test.py
  • test/assets/testAppSpec.json
  • ably/init.py
🧰 Additional context used
🧬 Code graph analysis (4)
test/ably/rest/restchannelpublish_test.py (1)
ably/rest/channel.py (1)
  • publish (129-155)
test/ably/rest/resthttp_test.py (3)
ably/rest/rest.py (1)
  • request (119-139)
ably/http/http.py (1)
  • headers (71-72)
ably/http/paginatedresult.py (1)
  • headers (133-134)
ably/types/message.py (2)
ably/types/presence.py (2)
  • action (62-63)
  • from_encoded (206-230)
ably/types/operations.py (4)
  • as_dict (26-34)
  • from_dict (37-45)
  • from_dict (63-67)
  • from_dict (85-89)
test/ably/rest/restchannelmutablemessages_test.py (6)
ably/rest/channel.py (9)
  • ably (350-351)
  • publish (129-155)
  • update_message (229-240)
  • name (354-355)
  • delete_message (242-253)
  • append_message (255-266)
  • get (389-400)
  • get_message (268-304)
  • get_message_versions (306-347)
ably/util/exceptions.py (1)
  • AblyException (9-90)
ably/util/crypto.py (2)
  • CipherParams (16-42)
  • algorithm (25-26)
ably/types/message.py (10)
  • MessageAction (100-107)
  • Message (110-358)
  • serial (55-56)
  • serial (197-198)
  • data (161-162)
  • version (193-194)
  • name (157-158)
  • description (67-68)
  • metadata (71-72)
  • action (201-202)
ably/types/operations.py (3)
  • MessageOperation (1-45)
  • description (19-20)
  • metadata (23-24)
test/ably/utils.py (1)
  • assert_waiter (179-191)
🪛 Gitleaks (8.30.0)
test/ably/rest/restchannelmutablemessages_test.py

[high] 158-158: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: check (3.12)
  • GitHub Check: check (3.14)
  • GitHub Check: check (3.8)
  • GitHub Check: check (3.11)
  • GitHub Check: check (3.10)
  • GitHub Check: check (3.9)
  • GitHub Check: check (3.13)
🔇 Additional comments (13)
ably/types/message.py (4)

25-97: LGTM! Well-structured MessageVersion class.

The class properly encapsulates version metadata with appropriate property accessors, as_dict() serialization that filters None values, and a from_dict() static method for deserialization. The use of to_text() for string normalization is consistent with the existing codebase patterns.


100-107: LGTM! MessageAction enum is well-defined.

The IntEnum choice is appropriate for protocol serialization compatibility. The values align with the protocol v5 specification.


122-139: LGTM! Message constructor extensions are clean.

The new serial, action, and version parameters are properly integrated with the existing constructor pattern, maintaining backward compatibility with default None values.


308-320: Consider the fallback MessageVersion construction when version is absent.

When no version dict is provided in the encoded message, a default MessageVersion is constructed using serial and timestamp from the message (line 320). This seems intentional for backward compatibility, but note that this creates a MessageVersion even for messages that may not have been published with mutable message support enabled.

Verify this fallback behavior is consistent with the Ably protocol v5 specification for messages without explicit version data.

ably/rest/channel.py (5)

13-27: LGTM! Import organization is appropriate.

The new imports for message types, operations, and exception handling are well-organized and necessary for the new functionality.


165-197: LGTM! _send_update properly handles None operation.

The fix at lines 180-187 correctly guards against AttributeError by checking if operation is falsy and setting version=None accordingly. This addresses the previously identified critical issue.


229-266: LGTM! Public wrapper methods are well-documented.

The update_message, delete_message, and append_message methods provide clean public interfaces with appropriate docstrings and delegate to the internal _send_update method correctly.


268-304: LGTM! get_message implementation is correct.

The method properly handles both string serials and Message objects, validates serial presence with a helpful error message, and uses the appropriate response handler.


306-347: LGTM! get_message_versions implementation is correct.

The paginated query approach is appropriate for version history retrieval. The serial extraction logic mirrors get_message which is acceptable given the different return types.

test/ably/rest/restchannelpublish_test.py (1)

402-424: LGTM! Test updated to align with new publish return type.

The test no longer captures the publish response since channel.publish now returns a PublishResult rather than the raw HTTP response. The test logic remains valid as it verifies encoding interoperability through the check_data and check_history waiters.

test/ably/rest/resthttp_test.py (1)

177-189: LGTM! Test updated to reflect API version 5.

The expected X-Ably-Version header value is correctly updated from '3' to '5' to match the protocol version bump in this PR.

test/ably/rest/restchannelmutablemessages_test.py (2)

25-49: LGTM! Test correctly validates update flow without operation metadata.

The test now properly validates the basic update flow without requiring description/metadata assertions. The assertions focus on core functionality: data update, version serial matching, and original serial preservation.


287-296: LGTM! Waiter condition correctly uses >= for tolerance.

The condition len(versions) >= count properly tolerates cases where more versions than expected might be returned, avoiding flaky test failures.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot temporarily deployed to staging/pull/659/features January 14, 2026 10:50 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ably/rest/channel.py (1)

102-124: Critical breaking change: publish_messages return type incompatible with existing tests and callers.

The method now returns PublishResult instead of the raw Response object. This breaks existing tests that expect response.status_code == 201 (will raise AttributeError since PublishResult has only a serials property) and any code relying on Response properties like status_code, headers, etc.

Either update the implementation to return the wrapped Response (preserving status_code access), or fix all tests and code paths expecting the old API. Additionally, avoid silently swallowing parse exceptions; at minimum log the failure.

🤖 Fix all issues with AI agents
In `@ably/rest/channel.py`:
- Around line 165-223: In _send_update, avoid accessing
operation.client_id/description/metadata when operation is None: compute a local
variable (e.g., version) that is MessageVersion(client_id=..., description=...,
metadata=...) only if operation is not None, otherwise set version = None, and
pass that version variable into the Message constructor instead of constructing
MessageVersion unconditionally; this preserves existing behavior when an
operation is provided and prevents AttributeError when operation is omitted.

In `@ably/types/message.py`:
- Around line 368-372: The single-message handler omits a context when calling
Message.from_encoded causing an AttributeError for delta-encoded messages;
update make_single_message_response_handler so
encrypted_message_response_handler calls Message.from_encoded(message,
cipher=cipher, context=None) (or change make_single_message_response_handler to
accept an optional context parameter and forward it) to explicitly supply a
context and match the behavior of make_message_response_handler.

In `@ably/types/operations.py`:
- Around line 4-12: The Operation class constructor accepts and stores client_id
but as_dict() and from_dict() omit it; update the Operation docstring to
document client_id and modify as_dict() to include "client_id" when
self.__client_id is not None, and update from_dict(cls, data) to read
data.get("client_id") and pass it into the constructor (or set the private
attribute) so serialization/deserialization round-trips client_id; if client_id
is intentionally server-only instead, instead add a clear docstring note that
client_id is read-only and deliberately omitted from as_dict() and from_dict().

In `@test/ably/rest/restchannelmutablemessages_test.py`:
- Around line 221-253: The test test_complete_workflow_publish_update_delete is
brittle because it asserts exactly 3 versions; change the check to wait for at
least 3 versions (use wait_until_get_all_message_version to poll until
len(versions) >= 3) and then assert the last three entries match the published
serial and the update/delete result serials (e.g. verify
versions[-3].version.serial == serial, versions[-2].version.serial ==
update_result.version_serial, versions[-1].version.serial ==
delete_result.version_serial) so the test tolerates extra older/newer entries
while still validating the workflow.
- Around line 295-304: The waiter in wait_until_get_all_message_version uses a
strict equality check (return len(versions) == count) which flakes if the API
returns more than count; change the condition to tolerate "at least N" items by
using len(versions) >= count when checking the result of await
channel.get_message_versions(serial) in the check_message_action closure so the
waiter succeeds once there are count or more versions.
- Around line 25-57: The Message created in test_update_message_success omits
the name and the test doesn't assert that update_result.version_serial is
present; to fix, supply the channel name when constructing the Message (e.g.,
set message.name to the channel's name) or explicitly assert that name is None
if omission is intentional, then add an assertion like assert
update_result.version_serial is not None (or truthy) before relying on it, and
optionally assert updated_message.name matches the message.name to ensure the
server returned the expected name.
- Around line 161-167: The test test_update_message_with_encryption currently
uses a hard-coded secret_key string in CipherParams which triggers gitleaks;
replace the literal with a generated synthetic key inside the test (e.g., derive
a 16-byte deterministic key via a utility or from a fixed seed using hashlib or
os.urandom seeded) and pass that variable to CipherParams(secret_key=...).
Update references in this test to use the new variable instead of the literal so
the key is not embedded as a string constant in the file.

In `@test/unit/mutable_message_test.py`:
- Around line 1-2: Imports are unsorted causing linter failure; reorder them so
third-party modules are grouped and alphabetized: place "from ably import
MessageAction, MessageOperation, MessageVersion, UpdateDeleteResult" before
"from ably.types.message import Message" and alphabetize the names in the
from-abyl import list (MessageAction, MessageOperation, MessageVersion,
UpdateDeleteResult).
- Around line 46-55: The test fails because Message.__init__ stores the provided
action value as-is and does not convert integer values to the MessageAction
enum; update Message.__init__ in ably/types/message.py so that if the incoming
action is an int (or already a MessageAction), it gets normalized to
MessageAction by doing a conversion like MessageAction(action) before assigning
to self.action (keep Message.from_encoded behavior consistent); ensure you
reference the Message.__init__ constructor and MessageAction enum and adjust any
type hints or tests if needed.
- Around line 108-123: The test does not verify that MessageOperation.client_id
is intentionally excluded from serialization; update
test_message_operation_serialization to construct a MessageOperation with
client_id (e.g., 'client_123'), call MessageOperation.as_dict() and assert that
'client_id' is not present in the returned dict while also asserting
operation.client_id == 'client_123' to ensure the attribute remains accessible;
keep the existing checks for description/metadata and ensure
MessageOperation.from_dict still round-trips correctly for the serialized
fields.
🧹 Nitpick comments (6)
test/assets/testAppSpec.json (1)

30-33: Inconsistent indentation style.

The new namespace entry uses spaces while the rest of the file uses tabs for indentation. Consider using tabs to match the existing style.

🔧 Proposed fix
 		{
 			"id": "canpublish",
 			"pushEnabled": true
 		},
-        {
-          "id": "mutable",
-          "mutableMessages": true
-        }
+		{
+			"id": "mutable",
+			"mutableMessages": true
+		}
 	],
ably/types/message.py (1)

308-316: Redundant else clause.

Line 316 (action = None) is redundant since action is already None when the condition on line 309 is false.

♻️ Suggested simplification
         # Handle action - can be MessageAction enum, int, or None
         if action is not None:
             try:
                 action = MessageAction(action)
             except ValueError:
                 # If it's not a valid action value, store as None
                 action = None
-        else:
-            action = None
test/ably/rest/restchannelmutablemessages_test.py (1)

70-101: Delete test: avoid sending data/name unless required by the API.
Keeping delete minimal (Message(serial=serial)) reduces the chance of server-side validation changes breaking the test for unrelated reasons.

ably/rest/channel.py (3)

165-223: Add timeout support to update/delete/append (parity with publish/get).
PATCH currently has no timeout path, which makes these calls harder to control in production code.


224-262: Public API: consider allowing serial: str input (ergonomics), not only Message.
You already accept serial_or_message for getters; symmetry for mutations would reduce boilerplate and avoid users constructing partially-filled Message objects.


263-343: Refactor: deduplicate serial extraction & validation between get_message and get_message_versions.
This logic is repeated and will drift (error text / error code / accepted types). A tiny private helper would keep it consistent.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 7926339 and d385246.

📒 Files selected for processing (7)
  • ably/__init__.py
  • ably/rest/channel.py
  • ably/types/message.py
  • ably/types/operations.py
  • test/ably/rest/restchannelmutablemessages_test.py
  • test/assets/testAppSpec.json
  • test/unit/mutable_message_test.py
🧰 Additional context used
🧬 Code graph analysis (3)
ably/__init__.py (2)
ably/types/message.py (1)
  • MessageVersion (25-97)
ably/types/operations.py (3)
  • MessageOperation (1-43)
  • PublishResult (46-65)
  • UpdateDeleteResult (68-87)
ably/types/operations.py (1)
ably/types/message.py (7)
  • client_id (63-64)
  • client_id (165-166)
  • description (67-68)
  • metadata (71-72)
  • as_dict (74-84)
  • as_dict (236-285)
  • from_dict (87-97)
test/ably/rest/restchannelmutablemessages_test.py (4)
ably/util/exceptions.py (1)
  • AblyException (9-90)
ably/types/message.py (10)
  • MessageAction (100-107)
  • Message (110-359)
  • serial (55-56)
  • serial (197-198)
  • data (161-162)
  • description (67-68)
  • metadata (71-72)
  • version (193-194)
  • name (157-158)
  • action (201-202)
ably/types/operations.py (3)
  • MessageOperation (1-43)
  • description (19-20)
  • metadata (23-24)
test/ably/utils.py (1)
  • assert_waiter (179-191)
🪛 GitHub Actions: Linting check
test/unit/mutable_message_test.py

[error] 1-1: ruff check failed: Import block is un-sorted or un-formatted. 1 error (organize imports). 1 fixable with the --fix option. Command: uv run ruff check

🪛 Gitleaks (8.30.0)
test/ably/rest/restchannelmutablemessages_test.py

[high] 165-165: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: check (3.13)
  • GitHub Check: check (3.14)
  • GitHub Check: check (3.12)
  • GitHub Check: check (3.9)
  • GitHub Check: check (3.11)
  • GitHub Check: check (3.8)
  • GitHub Check: check (3.10)
🔇 Additional comments (9)
ably/types/message.py (3)

25-97: LGTM!

The MessageVersion class is well-structured with proper to_text conversions for string fields and clean as_dict/from_dict serialization helpers.


100-107: LGTM!

The MessageAction enum correctly defines all message action types with appropriate integer values.


318-321: Verify intent of fallback MessageVersion construction.

When version is None in the response, a new MessageVersion is constructed using only serial and timestamp. This means newly created messages will always have a non-None version property, even if the server didn't provide version data. Confirm this is the intended behavior for backward compatibility.

ably/__init__.py (2)

10-11: LGTM!

New types are correctly exported from the public API surface.


20-20: API version bump from 3 to 5.

This is a significant protocol change. Ensure the changelog and migration notes document any breaking changes or new capabilities that require API version 5.

ably/types/operations.py (2)

46-65: LGTM!

PublishResult is straightforward with proper handling of the serials list.


68-87: LGTM!

UpdateDeleteResult correctly maps versionSerial from the server response to version_serial.

test/ably/rest/restchannelmutablemessages_test.py (2)

15-24: Add teardown/cleanup for the REST client (if BaseAsyncTestCase doesn’t already).
The autouse fixture creates self.ably but doesn’t explicitly close it; if the base class doesn’t handle this, tests may leak connections across the parametrized runs.


281-304: Test suite Python compatibility: avoid Message | None unless repo is 3.10+.
If this project still runs tests on Python 3.9 (or earlier), Message | None will SyntaxError. Use Optional[Message] instead.

Proposed change
-from typing import List
+from typing import List, Optional
 ...
-        message: Message | None = None
+        message: Optional[Message] = None

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

@ttypic ttypic force-pushed the AIT-196/rest-edits-deletes branch from d385246 to 737d738 Compare January 14, 2026 11:09
@github-actions github-actions bot temporarily deployed to staging/pull/659/features January 14, 2026 11:10 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@test/ably/rest/restchannelmutablemessages_test.py`:
- Around line 18-23: The fixture setup creates an Ably client via
TestApp.get_ably_rest but does not tear it down; modify the async pytest fixture
setup to yield after assigning self.test_vars and self.ably, and after the yield
ensure the Ably client is closed (call the client's close/async close method
used elsewhere) to release resources—mirror the pattern used in
TestRestChannelPublish.setup in restchannelpublish_test.py when locating the
change.
- Around line 281-293: The type annotation uses Python 3.10 union syntax; change
the declaration in wait_until_message_with_action_appears from "message: Message
| None" to "message: Optional[Message]" and add "from typing import Optional" at
the top of the file; ensure references in nested check_message_action and the
return type remain valid and keep using channel.get_message and assert_waiter
as-is for compatibility with Python 3.7+.
♻️ Duplicate comments (8)
test/unit/mutable_message_test.py (2)

108-123: Test doesn't verify client_id exclusion from serialization.

Per the past review, MessageOperation.as_dict() intentionally excludes client_id. Add a test case to verify this behavior:

def test_message_operation_client_id_exclusion():
    """Test that client_id is excluded from serialization but accessible as property."""
    operation = MessageOperation(
        client_id='client_123',
        description='Test operation',
        metadata={'key': 'value'}
    )
    op_dict = operation.as_dict()
    assert 'clientId' not in op_dict  # Intentionally excluded
    assert operation.client_id == 'client_123'  # Still accessible

46-55: Test relies on IntEnum equality semantics but doesn't verify type conversion.

This test may pass due to IntEnum equality (where 1 == MessageAction.MESSAGE_UPDATE), but Message.__init__ stores the raw integer without converting to MessageAction. Consider adding a type assertion to verify the expected behavior:

assert isinstance(message.action, MessageAction)  # Will fail currently

If integer storage is acceptable, update the docstring to clarify this. Otherwise, add int-to-enum conversion in Message.__init__.

ably/types/message.py (1)

367-371: Context parameter not explicitly provided to from_encoded.

As flagged in the past review, Message.from_encoded is called without context. If a delta-encoded message is received, line 302 (context.last_message_id) will raise AttributeError. While REST single-message responses likely don't contain delta messages, explicitly passing context=None would clarify intent.

Can Ably REST API single message responses contain delta-encoded messages?
ably/rest/channel.py (1)

180-192: Critical: _send_update crashes when operation is None.

Lines 188-190 access operation.client_id, operation.description, and operation.metadata, but operation defaults to None. Since update_message, delete_message, and append_message allow operation=None, this will raise AttributeError.

🐛 Proposed fix
     async def _send_update(
             self,
             message: Message,
             action: MessageAction,
             operation: MessageOperation = None,
             params: dict = None,
     ):
         """Internal method to send update/delete/append operations."""
         if not message.serial:
             raise AblyException(
                 "Message serial is required for update/delete/append operations",
                 400,
                 40000
             )

+        # Handle None operation
+        version = None
+        if operation:
+            version = MessageVersion(
+                client_id=operation.client_id,
+                description=operation.description,
+                metadata=operation.metadata,
+            )
+
         # Create a new message with the operation fields
         update_message = Message(
             name=message.name,
             data=message.data,
             client_id=message.client_id,
             serial=message.serial,
             action=action,
-            version=MessageVersion(
-                client_id=operation.client_id,
-                description=operation.description,
-                metadata=operation.metadata,
-            ),
+            version=version,
         )
test/ably/rest/restchannelmutablemessages_test.py (4)

46-56: Add assertion that update_result.version_serial is not None before depending on it.

The test asserts on update_result.version_serial at line 53 without first confirming it's set. A missing or None value would cause a confusing failure rather than a clear diagnostic.


161-182: Replace the hardcoded secret key to avoid triggering secret scanners.

The literal 'keyfordecrypt_16' is flagged by Gitleaks. Use a generated key instead.

Proposed fix
+    # Generate a deterministic test key to avoid secret scanner triggers
+    TEST_KEY = b'\x00' * 16  # 16 zero bytes for AES-128
+
     async def test_update_message_with_encryption(self):
         """Test updating an encrypted message"""
         # Create channel with encryption
         channel_name = self.get_channel_name('mutable:update_encrypted')
-        cipher_params = CipherParams(secret_key='keyfordecrypt_16', algorithm='aes')
+        cipher_params = CipherParams(secret_key=self.TEST_KEY, algorithm='aes')
         channel = self.ably.channels.get(channel_name, cipher=cipher_params)

247-252: Exact version count assertion may cause flaky tests.

Using len(versions) == 3 is brittle if the API returns additional entries. Consider >= 3 and validate the specific entries you care about.


295-304: Waiter condition should use >= to avoid flaky timeouts.

If the API returns more than count versions, the exact equality check will never succeed.

Proposed fix
     async def wait_until_get_all_message_version(self, channel, serial, count):
         versions: List[Message] = []
         async def check_message_versions():
             nonlocal versions
             versions = (await channel.get_message_versions(serial)).items
-            return len(versions) == count
+            return len(versions) >= count

         await assert_waiter(check_message_versions)

         return versions
🧹 Nitpick comments (4)
test/ably/rest/restrequest_test.py (1)

196-198: Consider asserting on the publish result for additional coverage.

The test correctly validates the 503 fallback behavior via default_route.called. Since publish now returns a PublishResult, you could optionally verify the result contains expected data from the fallback response to strengthen the test.

♻️ Optional enhancement
-            await ably.channels['test'].publish('test', 'data')
+            result = await ably.channels['test'].publish('test', 'data')
             assert default_route.called
+            # Optionally verify result from fallback
+            assert result is not None
ably/types/message.py (1)

122-139: Consider normalizing action to MessageAction in constructor.

The action parameter is stored as-is without conversion to MessageAction. While from_encoded (line 308-315) normalizes the action, direct construction with an integer won't produce a MessageAction instance. This inconsistency may cause type-checking issues.

♻️ Optional: Normalize action in constructor
         self.__serial = serial
-        self.__action = action
+        if action is not None and not isinstance(action, MessageAction):
+            try:
+                self.__action = MessageAction(action)
+            except ValueError:
+                self.__action = None
+        else:
+            self.__action = action
         self.__version = version
ably/rest/channel.py (2)

116-123: Consider narrowing exception handling or logging failures.

The bare except Exception silently swallows all errors during response parsing. This could hide legitimate issues. Consider logging the exception or catching specific expected exceptions.

♻️ Optional: Log parsing failures
         try:
             result_data = response.to_native()
             if result_data and isinstance(result_data, dict):
                 return PublishResult.from_dict(result_data)
             return PublishResult()
-        except Exception:
+        except Exception as e:
             # If response parsing fails, return empty PublishResult for backwards compatibility
+            log.debug("Failed to parse publish response: %s", e)
             return PublishResult()

216-222: Same exception handling concern as publish_messages.

Consider logging parsing failures here as well for consistency and debugging.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between d385246 and 737d738.

📒 Files selected for processing (10)
  • ably/__init__.py
  • ably/rest/channel.py
  • ably/types/message.py
  • ably/types/operations.py
  • test/ably/rest/restchannelmutablemessages_test.py
  • test/ably/rest/restchannelpublish_test.py
  • test/ably/rest/resthttp_test.py
  • test/ably/rest/restrequest_test.py
  • test/assets/testAppSpec.json
  • test/unit/mutable_message_test.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • test/assets/testAppSpec.json
  • ably/types/operations.py
  • ably/init.py
🧰 Additional context used
🧬 Code graph analysis (5)
test/unit/mutable_message_test.py (2)
ably/types/message.py (18)
  • MessageAction (100-107)
  • MessageVersion (25-97)
  • Message (110-358)
  • version (193-194)
  • serial (55-56)
  • serial (197-198)
  • timestamp (59-60)
  • timestamp (185-186)
  • client_id (63-64)
  • client_id (165-166)
  • as_dict (74-84)
  • as_dict (236-285)
  • description (67-68)
  • metadata (71-72)
  • name (157-158)
  • data (161-162)
  • action (201-202)
  • from_dict (87-97)
ably/types/operations.py (10)
  • MessageOperation (1-45)
  • UpdateDeleteResult (70-89)
  • client_id (15-16)
  • as_dict (26-34)
  • description (19-20)
  • metadata (23-24)
  • from_dict (37-45)
  • from_dict (63-67)
  • from_dict (85-89)
  • version_serial (81-82)
test/ably/rest/restrequest_test.py (1)
ably/rest/channel.py (2)
  • ably (345-346)
  • publish (129-155)
ably/rest/channel.py (8)
ably/types/message.py (15)
  • Message (110-358)
  • from_dict (87-97)
  • action (201-202)
  • serial (55-56)
  • serial (197-198)
  • name (157-158)
  • data (161-162)
  • client_id (63-64)
  • client_id (165-166)
  • version (193-194)
  • description (67-68)
  • metadata (71-72)
  • encrypt (204-220)
  • as_dict (74-84)
  • as_dict (236-285)
ably/types/operations.py (10)
  • MessageOperation (1-45)
  • PublishResult (48-67)
  • UpdateDeleteResult (70-89)
  • from_dict (37-45)
  • from_dict (63-67)
  • from_dict (85-89)
  • client_id (15-16)
  • description (19-20)
  • metadata (23-24)
  • as_dict (26-34)
ably/types/presence.py (6)
  • Presence (240-291)
  • action (62-63)
  • data (74-75)
  • client_id (66-67)
  • encrypt (125-145)
  • get (253-263)
ably/util/exceptions.py (4)
  • AblyException (9-90)
  • IncompatibleClientIdException (109-110)
  • catch_all (93-102)
  • from_dict (89-90)
ably/http/http.py (5)
  • response (110-111)
  • to_native (95-107)
  • version (83-84)
  • patch (251-254)
  • get (246-249)
ably/rest/rest.py (2)
  • http (108-109)
  • client_id (95-96)
ably/types/channelsubscription.py (3)
  • from_dict (47-49)
  • client_id (27-28)
  • as_dict (34-44)
ably/types/tokenrequest.py (1)
  • client_id (82-83)
test/ably/rest/restchannelmutablemessages_test.py (1)
ably/rest/channel.py (10)
  • ably (345-346)
  • publish (129-155)
  • update_message (224-235)
  • name (349-350)
  • delete_message (237-248)
  • append_message (250-261)
  • get (384-395)
  • cipher (357-358)
  • get_message (263-299)
  • get_message_versions (301-342)
test/ably/rest/resthttp_test.py (3)
ably/rest/rest.py (1)
  • request (119-139)
ably/http/http.py (1)
  • headers (71-72)
ably/http/paginatedresult.py (1)
  • headers (133-134)
🪛 Gitleaks (8.30.0)
test/ably/rest/restchannelmutablemessages_test.py

[high] 165-165: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: check (3.12)
  • GitHub Check: check (3.14)
  • GitHub Check: check (3.10)
  • GitHub Check: check (3.13)
  • GitHub Check: check (3.11)
  • GitHub Check: check (3.9)
  • GitHub Check: check (3.8)
🔇 Additional comments (21)
test/ably/rest/resthttp_test.py (1)

183-183: LGTM!

The test expectation correctly updated to match the API version bump to protocol v5.

test/unit/mutable_message_test.py (6)

1-2: Imports appear correctly ordered.

The imports are alphabetically sorted within the from ably import statement. If the linter still fails, verify the linter configuration expects from ably.types.message import Message to come before or after the from ably import line based on your project's import grouping rules.


5-17: LGTM!

Test correctly verifies that None values are filtered out from the serialized dictionary output.


18-27: LGTM!

Test correctly verifies None value filtering for MessageOperation.as_dict().


29-44: LGTM!

Test correctly verifies that Message stores and serializes action and serial fields when initialized with the enum value.


57-70: LGTM!

Tests correctly verify UpdateDeleteResult.from_dict() handles both valid dictionaries and None input gracefully.


73-80: LGTM!

Test correctly validates all MessageAction enum values match the expected integer mappings.

ably/types/message.py (4)

25-97: LGTM!

MessageVersion class is well-implemented with proper serialization/deserialization, None filtering, and consistent property accessors.


100-107: LGTM!

MessageAction enum correctly uses IntEnum for wire-protocol compatibility with appropriate action type values.


274-276: LGTM!

Serialization correctly handles the new fields: version is serialized via as_dict(), serial is included directly, and action is converted to int for wire compatibility.


308-320: LGTM!

The action normalization with try/except gracefully handles invalid values, and the version construction has appropriate fallback logic when the version dict is not present.

ably/rest/channel.py (4)

13-27: LGTM!

Import statements correctly include the new types and utilities needed for mutable message operations.


224-261: LGTM on method signatures and documentation.

The public API methods are well-documented with clear parameter descriptions. Note: the operation=None issue will be resolved by fixing _send_update.


263-299: LGTM!

Method correctly handles both string serial and Message object inputs, validates the serial presence, and provides a helpful error message directing users to enable the feature in dashboard settings.


301-342: LGTM!

Method correctly implements paginated version history retrieval with consistent serial validation and proper use of PaginatedResult.

test/ably/rest/restchannelpublish_test.py (1)

402-424: LGTM! Test correctly updated to reflect new publish() return type.

The test no longer captures or asserts on HTTP response status codes since publish() now returns a PublishResult object. The correctness validation is appropriately delegated to the check_data and check_history polling functions that verify the message was persisted correctly.

test/ably/rest/restchannelmutablemessages_test.py (5)

58-68: LGTM! Error handling test correctly verifies the expected status code and error message when serial is missing.


70-100: LGTM! The delete test correctly validates the MESSAGE_DELETE action, version serial, and operation metadata.


114-147: LGTM! The append test correctly validates that the resulting message action is MESSAGE_UPDATE with concatenated data, which aligns with the expected behavior.


184-219: LGTM! Both tests appropriately validate the params passthrough and batch publish serial results.


254-279: LGTM! The test validates string data append with concatenation.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

@ttypic ttypic requested a review from owenpearson January 14, 2026 11:30
@ttypic ttypic force-pushed the AIT-196/rest-edits-deletes branch from 737d738 to a63e968 Compare January 14, 2026 11:31
@github-actions github-actions bot temporarily deployed to staging/pull/659/features January 14, 2026 11:31 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@test/ably/rest/restchannelmutablemessages_test.py`:
- Around line 41-51: The test calls channel.update_message(message) but later
asserts the version.description and version.metadata, so create and pass a
MessageOperation with the expected values when updating; e.g. construct a
MessageOperation with description 'Fixed typo' and metadata {'reason':
'correction'} and pass it to update_message (the same call that leads to
MessageAction.MESSAGE_UPDATE and wait_until_message_with_action_appears) so
update_result.version_serial/description/metadata match the assertions.
♻️ Duplicate comments (3)
test/ably/rest/restchannelmutablemessages_test.py (3)

18-23: Missing teardown: add yield and close the Ably client.

The fixture creates an Ably client but doesn't clean it up, which may cause resource leaks during test runs.


156-160: Replace the hard-coded secret_key literal.

Hard-coded key-like strings trigger secret scanners and can block merges. Use a generated synthetic key instead.


275-287: Use Optional[Message] instead of Message | None for Python 3.7+ compatibility.

The project supports Python 3.7+ but the | union syntax requires Python 3.10+. Add Optional to the imports and change the type annotation.

🧹 Nitpick comments (6)
test/ably/rest/restrequest_test.py (1)

196-197: Consider adding an assertion on the publish result.

The test now only verifies that default_route.called is True, but no longer asserts that the fallback response was actually processed correctly. Since publish now returns a PublishResult, you could assert on the returned value to strengthen coverage.

💡 Optional enhancement
-            await ably.channels['test'].publish('test', 'data')
+            result = await ably.channels['test'].publish('test', 'data')
             assert default_route.called
+            # Optionally verify the fallback response was processed
+            assert result is not None
ably/types/operations.py (1)

4-12: Docstring incomplete: client_id parameter not documented.

The constructor accepts client_id (line 4) and it's now properly serialized in as_dict/from_dict, but the docstring (lines 6-8) doesn't document this parameter. Consider adding documentation to clarify its purpose.

📝 Add client_id to docstring
     def __init__(self, client_id=None, description=None, metadata=None):
         """
         Args:
+            client_id: The client ID that performed the operation (typically read-only, populated by server).
             description: Optional description of the operation.
             metadata: Optional dict of metadata key-value pairs associated with the operation.
         """
ably/types/message.py (1)

308-315: Minor: redundant action = None assignment.

The else branch (lines 314-315) assigns action = None, but action is already None when this branch is reached (per the condition on line 308).

♻️ Simplify
         if action is not None:
             try:
                 action = MessageAction(action)
             except ValueError:
                 # If it's not a valid action value, store as None
                 action = None
-        else:
-            action = None
ably/rest/channel.py (3)

116-123: Bare except Exception may mask real errors.

This broad exception handler silently returns an empty PublishResult for any parsing failure, which could mask legitimate errors (e.g., malformed response, network issues during parsing). Consider catching more specific exceptions or at least logging the error.

♻️ Add logging for debugging
         try:
             result_data = response.to_native()
             if result_data and isinstance(result_data, dict):
                 return PublishResult.from_dict(result_data)
             return PublishResult()
         except Exception:
             # If response parsing fails, return empty PublishResult for backwards compatibility
+            log.debug("Failed to parse publish response, returning empty PublishResult")
             return PublishResult()

221-227: Same concern: broad except Exception for response parsing.

Similar to publish_messages, this silently swallows all exceptions. Consider adding debug logging for consistency and easier debugging.

♻️ Add logging
         try:
             result_data = response.to_native()
             if result_data and isinstance(result_data, dict):
                 return UpdateDeleteResult.from_dict(result_data)
             return UpdateDeleteResult()
         except Exception:
+            log.debug("Failed to parse update/delete response, returning empty UpdateDeleteResult")
             return UpdateDeleteResult()

268-294: Consider extracting serial extraction to a helper method.

The serial extraction logic (lines 281-286 and 320-325) is duplicated between get_message and get_message_versions. A small helper could reduce duplication.

♻️ Extract helper method
def _extract_serial(self, serial_or_message):
    """Extract serial from string or Message object."""
    if isinstance(serial_or_message, str):
        return serial_or_message
    elif isinstance(serial_or_message, Message):
        return serial_or_message.serial
    return None

Then use in both methods:

-        if isinstance(serial_or_message, str):
-            serial = serial_or_message
-        elif isinstance(serial_or_message, Message):
-            serial = serial_or_message.serial
-        else:
-            serial = None
+        serial = self._extract_serial(serial_or_message)

Also applies to: 306-333

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 737d738 and a63e968.

📒 Files selected for processing (10)
  • ably/__init__.py
  • ably/rest/channel.py
  • ably/types/message.py
  • ably/types/operations.py
  • test/ably/rest/restchannelmutablemessages_test.py
  • test/ably/rest/restchannelpublish_test.py
  • test/ably/rest/resthttp_test.py
  • test/ably/rest/restrequest_test.py
  • test/assets/testAppSpec.json
  • test/unit/mutable_message_test.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • test/ably/rest/resthttp_test.py
  • test/assets/testAppSpec.json
  • test/ably/rest/restchannelpublish_test.py
  • test/unit/mutable_message_test.py
🧰 Additional context used
🧬 Code graph analysis (5)
ably/types/operations.py (1)
ably/types/message.py (7)
  • client_id (63-64)
  • client_id (165-166)
  • description (67-68)
  • metadata (71-72)
  • as_dict (74-84)
  • as_dict (236-285)
  • from_dict (87-97)
test/ably/rest/restrequest_test.py (2)
ably/rest/channel.py (2)
  • ably (350-351)
  • publish (129-155)
ably/realtime/realtime_channel.py (1)
  • publish (393-493)
test/ably/rest/restchannelmutablemessages_test.py (8)
ably/rest/channel.py (9)
  • ably (350-351)
  • publish (129-155)
  • update_message (229-240)
  • name (354-355)
  • delete_message (242-253)
  • append_message (255-266)
  • get (389-400)
  • get_message (268-304)
  • get_message_versions (306-347)
ably/util/exceptions.py (1)
  • AblyException (9-90)
ably/util/crypto.py (2)
  • CipherParams (16-42)
  • algorithm (25-26)
ably/types/message.py (10)
  • MessageAction (100-107)
  • Message (110-358)
  • serial (55-56)
  • serial (197-198)
  • data (161-162)
  • version (193-194)
  • description (67-68)
  • metadata (71-72)
  • name (157-158)
  • action (201-202)
ably/types/operations.py (3)
  • MessageOperation (1-45)
  • description (19-20)
  • metadata (23-24)
test/ably/testapp.py (2)
  • TestApp (37-115)
  • get_test_vars (41-70)
test/ably/utils.py (1)
  • assert_waiter (179-191)
test/ably/rest/restrequest_test.py (1)
  • setup (16-27)
ably/types/message.py (2)
ably/types/presence.py (8)
  • timestamp (78-79)
  • client_id (66-67)
  • get (253-263)
  • action (62-63)
  • id (58-59)
  • connection_id (70-71)
  • extras (87-88)
  • from_encoded (206-230)
ably/types/operations.py (7)
  • client_id (15-16)
  • description (19-20)
  • metadata (23-24)
  • as_dict (26-34)
  • from_dict (37-45)
  • from_dict (63-67)
  • from_dict (85-89)
ably/__init__.py (2)
ably/types/message.py (2)
  • MessageAction (100-107)
  • MessageVersion (25-97)
ably/types/operations.py (3)
  • MessageOperation (1-45)
  • PublishResult (48-67)
  • UpdateDeleteResult (70-89)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: check (3.8)
  • GitHub Check: check (3.10)
  • GitHub Check: check (3.12)
  • GitHub Check: check (3.14)
  • GitHub Check: check (3.11)
  • GitHub Check: check (3.9)
  • GitHub Check: check (3.13)
🔇 Additional comments (21)
ably/types/operations.py (2)

48-67: LGTM!

PublishResult class is well-structured with proper null handling - defaults to empty list when serials is None or when obj is None in from_dict.


70-89: LGTM!

UpdateDeleteResult class follows the same clean pattern as PublishResult with appropriate null-safe from_dict implementation.

ably/types/message.py (2)

25-97: LGTM!

MessageVersion is well-implemented with proper string normalization via to_text(), consistent with patterns in the codebase. The as_dict/from_dict methods correctly handle None values.


100-107: LGTM!

MessageAction enum values align with the expected protocol message action types.

ably/__init__.py (2)

10-11: LGTM!

New types are correctly imported and exposed in the public API surface.


20-21: API version bumped from '3' to '5' — intentional and properly scoped.

The version bump is confirmed by the commit message and aligns with the introduction of message update, delete, and append operations. Ably's platform supports mutable messages natively, and the implementation (MessageOperation, PublishResult, UpdateDeleteResult classes) properly handles the spec requirements for message mutations.

ably/rest/channel.py (4)

13-27: LGTM!

Imports are correctly organized and include all necessary types for the new message operations.


180-197: LGTM! Proper guard against None operation.

The check at lines 180-187 correctly handles the case when operation is None, preventing the AttributeError that was raised in the previous version.


229-266: LGTM!

The update_message, delete_message, and append_message public methods are clean delegations to _send_update with appropriate action types. Documentation is clear and consistent.


218-218: The http.patch method exists and is properly implemented as an async method in ably/http/http.py. The code usage is correct.

test/ably/rest/restchannelmutablemessages_test.py (11)

1-12: LGTM!

Imports are appropriate for the test module's needs.


53-63: LGTM!

Good negative test validating the 400 error when serial is missing.


65-95: LGTM!

Well-structured test with proper operation passing and comprehensive assertions.


97-107: LGTM!

Consistent error handling test for delete operation.


109-142: LGTM!

Good test for append functionality with proper verification of concatenated data.


144-154: LGTM!

Consistent error handling test.


179-198: LGTM!

Test validates that params can be passed without error. Consider adding server-side verification if the params have observable effects.


200-214: LGTM!

Good test verifying that PublishResult returns the expected number of serials.


242-246: Verify assertion semantics: comparing version serial to message serial.

Line 244 compares versions[0].version.serial (the version serial) to serial (the message serial from result.serials[0]). In other tests, version serials are compared to *_result.version_serial while message serials are compared separately (e.g., line 92 vs 95 in test_delete_message_success).

If the initial version serial equals the message serial by design, this is fine. Otherwise, consider:

  • Comparing message serials: versions[0].serial == serial
  • Or storing the initial version serial from the publish result

248-273: LGTM!

Good test specifically validating string data concatenation in append operations.


289-298: LGTM!

The waiter correctly uses >= count to tolerate eventual consistency scenarios where more versions than expected may be present.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

- Introduced `_send_update` method to handle message operations (update, delete, append) on the channel.
- Added `update_message`, `delete_message`, and `append_message` methods to enable respective operations via the API.
- Implemented `MessageOperation`, `PublishResult`, and `UpdateDeleteResult` classes for handling operation metadata and results.
- Bumped `api_version` to 5.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants