fix(json): scope JSON callbacks to sub-client, not parent Redis client (#3937)#4087
fix(json): scope JSON callbacks to sub-client, not parent Redis client (#3937)#4087me-saurabhkohli wants to merge 6 commits into
Conversation
Calling r.json() previously registered all JSON.* response callbacks
directly on the parent Redis client's response_callbacks dict via
set_response_callback(). This caused a stateful side-effect: any
subsequent bare execute_command('JSON.GET', ...) call on the same
client instance would return a decoded dict instead of raw bytes, even
when decode_responses=False.
Root cause: _JSONBase.__init__ called self.client.set_response_callback()
for each entry in _MODULE_CALLBACKS, permanently mutating the shared
callback table on the parent client.
Fix:
- Remove the set_response_callback loop from _JSONBase.__init__
- Remove the self.execute_command = client.execute_command instance
attribute that shadowed the class method
- Add execute_command() on JSON (sync) and AsyncJSON (async) that calls
the parent's execute_command and then applies _MODULE_CALLBACKS locally
- Update pipeline() to merge _MODULE_CALLBACKS into the pipeline's own
private callback snapshot so pipeline JSON calls continue to work
The parent client's response_callbacks is now never mutated by r.json().
Regression tests added in tests/test_json_callback_isolation.py:
- Verifies no JSON.* keys appear in parent response_callbacks after json()
- Verifies multiple json() calls don't accumulate callbacks
- Verifies two independent clients don't share callbacks
- Verifies _MODULE_CALLBACKS lives on the sub-client, not the parent
All four tests use @pytest.mark.fixed_client (no live Redis required).
Fixes redis#3937
|
Hi, I’m Jit, a friendly security platform designed to help developers build secure applications from day zero with an MVS (Minimal viable security) mindset. In case there are security findings, they will be communicated to you as a comment inside the PR. Hope you’ll enjoy using Jit. Questions? Comments? Want to learn more? Get in touch with us. |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Fixes issue #3937 by ensuring RedisJSON response callbacks remain isolated to the JSON sub-client, preventing pollution of the parent Redis client’s response_callbacks and incorrect decoding on subsequent raw execute_command("JSON.*") calls.
Changes:
- Stop registering JSON module callbacks on the parent client in
_JSONBase.__init__. - Add local
execute_commandimplementations onJSON/AsyncJSONto apply JSON callbacks without mutating the parent client. - Update JSON pipeline creation to include JSON callbacks via a merged, pipeline-local callback map.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| tests/test_json_callback_isolation.py | Adds regression/unit tests asserting r.json() does not mutate parent callback tables and keeps JSON callbacks scoped. |
| redis/commands/json/init.py | Removes parent callback registration, adds local callback application in execute_command, and merges callbacks into JSON pipelines. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Hey @me-saurabhkohli, thank you for your contribution! The over all approach looks good! Can you please check the cursor bot's comments and test failures? |
…into ClusterPipeline Addresses two bugs flagged in review: 1. TypeError from **kwargs forwarding (high severity) execute_command was passing all **kwargs to module callbacks but most callbacks (_decode, bulk_of_jsons, int, lambdas) don't accept **kwargs. Methods like JSON.GET pass redis-internal keys= and options= kwargs which caused TypeError on nearly every JSON command. In JSON.get() this was silently swallowed, returning None instead of the actual data. Fix: only forward _json_path (the one JSON-module kwarg); strip everything else. 2. ClusterPipeline missing JSON callbacks (medium severity) pipeline() was passing self.client.cluster_response_callbacks directly to ClusterPipeline, but those no longer contain JSON callbacks since we stopped registering them on the parent client in __init__. Fix: merge _MODULE_CALLBACKS into a copy of cluster_response_callbacks before passing to ClusterPipeline, matching the regular Pipeline path. Tests: add two new regression tests in test_json_callback_isolation.py covering both fixes.
me-saurabhkohli
left a comment
There was a problem hiding this comment.
Thanks for flagging those — both issues fixed in the latest commit:
1. TypeError from **kwargs forwarding (high severity)
execute_command was passing all **kwargs straight to the module callback, but most callbacks (_decode, bulk_of_jsons, int, simple lambdas) do not accept keyword arguments. Methods like JSON.GET pass redis-internal kwargs like keys= and options= which caused a TypeError on nearly every operation — silently swallowed by the try/except TypeError in get(), returning None instead of real data.
Fix: only _json_path (the one JSON-module kwarg) is forwarded; everything else is stripped before the callback is invoked.
2. ClusterPipeline missing JSON callbacks (medium severity)
pipeline() was passing self.client.cluster_response_callbacks directly to ClusterPipeline, but those no longer contain JSON callbacks since we stopped registering them on the parent client in __init__. The regular Pipeline path already merged _MODULE_CALLBACKS into a copy — the cluster path now does the same.
Both fixes covered by new tests in test_json_callback_isolation.py.
|
Hi @petyaslavova, Thanks for the review and kind words! Just to close the loop on both items: Cursor Bugbot issues
Test failures in Happy to make any further adjustments if needed. Thanks! |
| accept arbitrary keyword arguments. | ||
| """ | ||
| cmd = args[0] if args else "" | ||
| response = self.client.execute_command(*args, **kwargs) |
There was a problem hiding this comment.
JSON.execute_command() now always assumes self.client.execute_command() returned a Redis response and immediately applies the JSON callback. That breaks when the JSON sub-client is created from a pipeline, because pipeline execute_command() returns the queued pipeline object, not a server response.
Search handles this distinction explicitly. For example, SearchCommands.search() calls self.execute_command(...), then checks:
if isinstance(res, Pipeline):
return resThat guard is important because module callbacks must run at pipeline execution time, not when the command is queued.
Could you please add pipelines handling similar to what Search does: return the queued pipeline object unchanged?
There was a problem hiding this comment.
Hi @petyaslavova , Thanks for the detailed feedback!
I have fixed both the concerns, added a check using isInstance(response, (Pipeline, ClusterPIpeline)) similar to Search module's guard pattern.
| """ | ||
| cmd = args[0] if args else "" | ||
| response = self.client.execute_command(*args, **kwargs) | ||
| if cmd in self._MODULE_CALLBACKS: |
There was a problem hiding this comment.
With the original implementation the callbacks, have been check in CaseInsensitiveDict and calls like j.execute_command("json.get", "k") have been successfully post-processed.
self._MODULE_CALLBACKS is a regular dict and we are loosing this old behaviour.
There was a problem hiding this comment.
Fixed by upper-casing the command name before the lookup, also added test coverage for lower and mixed case inputs.
There was a problem hiding this comment.
There is a second pipeline-specific piece here beyond returning the queued pipeline object: pipeline decoding happens later, inside Pipeline.execute(), by looking up each queued command in the pipeline’s own callback table.
For example, the sync pipeline execution path eventually does roughly:
for r, cmd in zip(response, commands):
args, options = cmd
command_name = args[0]
if command_name in self.response_callbacks:
r = self.response_callbacks[command_name](r, **options)So if JSON callbacks only live on the JSON sub-client’s _MODULE_CALLBACKS, p.execute() cannot apply them to the individual JSON.* responses. This matters for the existing pipeline.json() access path:
p = redis.Redis().pipeline()
p.json().set("k", ".", {"a": 1})
p.json().get("k")
res = p.execute()Even if JSON.execute_command() is changed to return the pipeline object unchanged when queueing, the pipeline still needs JSON.SET, JSON.GET, etc. registered in its private response_callbacks map before execute() runs. Otherwise the queued JSON commands will return raw Redis wire responses instead of the module-level Python shapes.
Search handles this by constructing module-specific pipeline classes that copy the parent callbacks and register module callbacks on the pipeline-local map. For JSON, we should make sure both supported pipeline entry points get execution-time callbacks:
r.json().pipeline() # already intended to merge JSON callbacks into the new pipeline
r.pipeline().json() # should also ensure the existing pipeline gets JSON callbacksWithout that, the fix only avoids parent-client pollution, but leaves pipeline execution without the callbacks it needs to decode each queued JSON response.
There was a problem hiding this comment.
This was the trickiest one, thanks for the detailed explanation.
For the r.pipeline().json() path, I have added check in __init__() at line 136, when the client is a Pipeline, we merge _MODULE_CALLBACKS into the pipeline's own response_callbacks.
One critical use case I ran into - Pipeline.response_callbacks is a shared reference to the parent client's dict (passed by reference in Redis.pipeline()). So we have to replace it with a private copy via dict(client.response_callbacks) before calling .update() otherwise we'd re-introduce the exact parent pollution that #3937 was about.
Tests added for both entry points, callback isolation, idempotent p.json() calls, and a cluster negative assertion confirming the parent's cluster_response_callbacks stays clean.
There was a problem hiding this comment.
There are a few test gaps that would make this regression much easier to catch.
The current isolation tests cover “r.json() does not mutate the parent callback table”, but they do not fully cover the pipeline execution paths where callbacks must move to a pipeline-local table instead.
I think we need tests for at least these cases:
p = redis.Redis().pipeline()
result = p.json().set("k", ".", {"a": 1})
assert result is p
assert "JSON.SET" in p.response_callbacks
assert "JSON.SET" not in p.connection_pool.redis_client.response_callbacks # or equivalent parent assertionAnd, more importantly, a test that verifies callbacks are applied when execute() processes each queued JSON response:
p = r.pipeline()
p.json().set("k", ".", {"a": 1})
p.json().get("k")
assert p.execute() == [True, {"a": 1}]That should be covered for both supported JSON pipeline entry points:
r.json().pipeline()
r.pipeline().json()The async path should have the same coverage too:
p = r.pipeline()
await p.json().set("k", ".", {"a": 1})
await p.json().get("k")
assert await p.execute() == [True, {"a": 1}]There should also be a regression test proving callback isolation and execution-time decoding happen together:
r = redis.Redis()
p = r.pipeline()
p.json().get("k")
assert "JSON.GET" not in r.response_callbacks
assert "JSON.GET" in p.response_callbacksFor cluster, the new test currently uses a mocked RedisCluster but does not populate instance attributes such as nodes_manager, so it fails before it reaches the callback assertion. It would be useful to replace/fix that test so it verifies the actual contract:
json_pipeline = r.json().pipeline()
assert "JSON.GET" in json_pipeline.cluster_response_callbacks
assert "JSON.GET" not in r.cluster_response_callbacksWithout these tests, the PR can pass the parent-client isolation checks while still leaving queued JSON commands undecoded at Pipeline.execute() time.
There was a problem hiding this comment.
Agreed. Added more test coverage, please review and let me know if few extra corner use cases would need to be removed from this PR. Thanks!
- Add isinstance guard in JSON/AsyncJSON.execute_command() to return pipeline object unchanged when client is a Pipeline (mirrors Search module pattern) - Upper-case command name before _MODULE_CALLBACKS lookup to preserve case-insensitive behavior lost when moving away from CaseInsensitiveDict - Merge _MODULE_CALLBACKS into pipeline's private response_callbacks copy in _JSONBase.__init__() when client is a Pipeline, enabling the r.pipeline().json() entry point (r.json().pipeline() was already handled) - Copy response_callbacks via dict() before update to avoid mutating the parent client's shared dict reference - Expand test coverage: _json_path forwarding, non-JSON passthrough, pipeline chaining contract, connection_pool isolation, idempotent p.json() calls, async path isolation, cluster negative assertion, and async integration test
redis.asyncio.client.Pipeline and redis.asyncio.cluster.ClusterPipeline are separate class hierarchies from their sync counterparts (no inheritance). Add them to all isinstance guards in _JSONBase.__init__, JSON.execute_command, and AsyncJSON.execute_command so that async_pipeline.json() correctly merges callbacks and the pipeline guard detects async pipeline responses. Tests added: - test_async_pipeline_type_merges_callbacks - test_async_pipeline_execute_command_returns_pipeline
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 2650cea. Configure here.
| # before merging to avoid polluting the parent. | ||
| if isinstance(client, (redis.client.Pipeline, redis.asyncio.client.Pipeline)): | ||
| client.response_callbacks = dict(client.response_callbacks) | ||
| client.response_callbacks.update(self._MODULE_CALLBACKS) |
There was a problem hiding this comment.
Pipeline loses case-insensitive callbacks
Medium Severity
When p.json() (or cluster p.json()) runs, response_callbacks / cluster_response_callbacks is replaced with a plain dict copy instead of CaseInsensitiveDict. Pipeline.execute() then looks up callbacks with the queued command string as-is, so mixed- or lower-case commands (e.g. json.get) no longer decode after this path.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 2650cea. Configure here.
There was a problem hiding this comment.
Intentionally kept dict() as followed in Search / TimeSeries module.
CC: @petyaslavova


What this PR does
Fixes a stateful side-effect where calling
r.json()permanently mutates the parent Redis client'sresponse_callbacksdict, causing subsequent bareexecute_command('JSON.GET', ...)calls on the same client to return decoded dicts instead of raw bytes (even withdecode_responses=False).Fixes
Fixes #3937
Root Cause
_JSONBase.__init__calledself.client.set_response_callback(key, value)for every entry in_MODULE_CALLBACKS:After the first
r.json()call, the parent client has 13 JSON.* callbacks registered. Any subsequentexecute_command('JSON.GET', ...)on the parent returns a decoded dict regardless ofdecode_responses.Fix
set_response_callbackloop from_JSONBase.__init__self.execute_command = client.execute_commandinstance attribute (it shadowed the class method)execute_command()onJSON(sync) andasync execute_command()onAsyncJSON— each calls the parent and applies_MODULE_CALLBACKSlocally to the raw response, without touching the parent's callback tablepipeline()to merge_MODULE_CALLBACKSinto the pipeline's privateresponse_callbackssnapshot so pipeline JSON calls continue to decode correctlyTests
New file:
tests/test_json_callback_isolation.py— 4 tests, all@pytest.mark.fixed_client(no live Redis required):test_json_method_does_not_mutate_parent_response_callbacksr.json()test_json_method_called_multiple_times_does_not_accumulate_callbackstest_two_independent_clients_do_not_share_json_callbackstest_json_subclient_callbacks_are_registered_on_json_object_not_parent_MODULE_CALLBACKSon sub-client, not parentChecklist
redis/commands/json/__init__.pyonly — no other modules changedJSON) and async (AsyncJSON) paths both fixedr.json().get()behaviour unchangedtest_retry.pyconfirmed unrelated (reproduce onmasterwithout this patch)Note
Medium Risk
Touches core command/response handling for the JSON module across sync, async, and cluster pipelines; behavior change is intentional but any missed entry path could leave raw or double-decoded responses.
Overview
Fixes #3937 by stopping
r.json()from registering JSON module decoders on the parentRedisclient’s sharedresponse_callbacks, which had caused bareexecute_command("JSON.*", …)on that client to return parsed objects even whendecode_responses=False.JSON / AsyncJSON now apply
_MODULE_CALLBACKSin overriddenexecute_command(case-insensitive command names, pipeline return-value guard, and only_json_pathforwarded to callbacks—not internal kwargs likekeys). For pipelines (r.pipeline().json(),r.json().pipeline(), sync/async/cluster), JSON callbacks are merged into a private copy of the pipeline’s callback dict soexecute()still decodes JSON responses without touching the parent.Adds
tests/test_json_callback_isolation.pywith unit and integration coverage for parent isolation, pipeline/cluster/async paths, and end-to-end pipeline decoding.Reviewed by Cursor Bugbot for commit 2650cea. Bugbot is set up for automated code reviews on this repo. Configure here.