Skip to content

KeyError result #449

Closed
Closed

Description

@pedro-beemon

Checklist before reporting

  • I have searched for similar issues and didn't find a duplicate. (KeyError result #449 is my own earlier placeholder for this same crash — see the bottom of this report.)
  • I have updated to the latest version of pydoll to verify the issue still exists.

pydoll Version

2.25.0 (commit a93d3ba)

Python Version

3.11

Operating System

Linux

Bug Description

FindElementsMixin._find_elements indexes the Runtime.getProperties response
directly:

for query in query_response['result']['result']:

query_response: GetPropertiesResponse = await self._execute_command(
    RuntimeCommands.get_properties(object_id=object_id)
)
response: list[str] = []
for query in query_response['result']['result']:   # <-- line 564

ConnectionHandler.execute_command returns the decoded CDP message as-is
(return json.loads(response)), and it does not raise on protocol errors. When
CDP answers with an error, the message has no result key at all:

{"id": 42, "error": {"code": -32000, "message": "Could not find object with given id"}}

so query_response['result'] raises KeyError: 'result', which escapes
_find_elements and therefore every public caller (find(..., find_all=True),
query(...), find_or_wait_element, and the WebElement variants of the same).

This is a race, not a bad selector: Runtime.evaluate succeeds and hands back an
array objectId, and between that call and the Runtime.getProperties call the
object stops being resolvable — the page navigated, the execution context was
destroyed, the iframe detached, or the remote object was released. Typical CDP
messages seen here are Could not find object with given id,
Cannot find context with specified id and
Inspected target navigated or closed. It is most visible on dynamic pages and
inside find(..., timeout=...) polling loops, where a navigation landing in that
window turns a "no elements right now" situation into a hard crash.

Note that the rest of the same method is already defensive about exactly this
class of failure, which is what makes line 564 look like an oversight:

  • line 553 guards the Runtime.evaluate response with a .get() chain (via the
    same idea as _has_object_id_key, find_elements_mixin.py:903);
  • _describe_node (find_elements_mixin.py:684) explicitly checks
    if 'error' in response and returns {};
  • _get_object_attributes (find_elements_mixin.py:594) tolerates an empty node;
  • the per-element loop right below (line 572) even wraps _describe_node in
    try/except KeyError: continue.

Only the getProperties response is indexed unguarded.

Steps to Reproduce

  1. Call any multi-element search (tab.find(tag_name='div', find_all=True),
    tab.query(...), or element.find(...)) on a page that navigates while the
    search is in flight.
  2. Have the page commit a navigation (or detach the iframe / destroy the
    execution context) after Runtime.evaluate returned the array objectId but
    before Runtime.getProperties runs.
  3. KeyError: 'result' is raised instead of an empty list / ElementNotFound.

Because the window is small in a real browser, the snippet below reproduces it
deterministically by stubbing only _execute_command — every other line
executed is real library code.

Code Example

import asyncio

from pydoll.constants import By
from pydoll.elements.mixins.find_elements_mixin import FindElementsMixin


class FakeMixin(FindElementsMixin):
    """Minimal harness: only _execute_command is stubbed, the rest is real code."""

    def __init__(self):
        self._connection_handler = None
        self._calls = 0

    async def _execute_command(self, command):
        self._calls += 1
        if self._calls == 1:
            # Runtime.evaluate succeeded and returned an array RemoteObject
            return {'id': 1, 'result': {'result': {'objectId': 'array-object-id'}}}
        # Runtime.getProperties failed: object released / context gone
        return {
            'id': 2,
            'error': {'code': -32000, 'message': 'Could not find object with given id'},
        }


async def main():
    obj = FakeMixin()
    # raise_exc=False, so the documented contract is "return an empty list"
    print(await obj._find_elements(By.CSS_SELECTOR, 'div', raise_exc=False))


asyncio.run(main())

Expected Behavior

A CDP error on Runtime.getProperties should be handled the same way the method
already handles a failed Runtime.evaluate: return [] when raise_exc=False,
or raise ElementNotFound when raise_exc=True. A KeyError leaking out of an
internal dict access is never a useful signal for a caller — it cannot be caught
by except ElementNotFound and it makes find(..., timeout=...) loops abort
instead of retrying.

Actual Behavior

KeyError: 'result' propagates out of _find_elements, regardless of
raise_exc.

Relevant Log Output

Traceback (most recent call last):
  File "repro.py", line 31, in <module>
    asyncio.run(main())
  File "/usr/lib/python3.11/asyncio/runners.py", line 190, in run
    return runner.run(main)
  File "/usr/lib/python3.11/asyncio/runners.py", line 118, in run
    return self._loop.run_until_complete(task)
  File "/usr/lib/python3.11/asyncio/base_events.py", line 654, in run_until_complete
    return future.result()
  File "repro.py", line 27, in main
    elements = await obj._find_elements(By.CSS_SELECTOR, 'div', raise_exc=False)
  File "pydoll/elements/mixins/find_elements_mixin.py", line 564, in _find_elements
    for query in query_response['result']['result']:
                 ~~~~~~~~~~~~~~^^^^^^^^^^
KeyError: 'result'

Additional Context

Same pattern elsewhere. WebElement._get_family_elements has the identical
unguarded access on the getProperties response — it carefully guards the
evaluate response with _has_object_id_key at line 1005 and then indexes
properties_response['result']['result'] at line 1016:

for prop in properties_response['result']['result']:

Worth fixing in the same change.

Suggested fix (small and local, in the spirit of the existing guards):

query_response: GetPropertiesResponse = await self._execute_command(
    RuntimeCommands.get_properties(object_id=object_id)
)
properties = query_response.get('result', {}).get('result', [])
if not properties:
    if raise_exc:
        raise ElementNotFound()
    return []

response: list[str] = []
for query in properties:
    ...

A slightly broader alternative would be to make Response carry
error: NotRequired[...] in pydoll/protocol/base.py and add one shared helper
(next to _has_object_id_key) that all ['result']['result'] call sites go
through — grep -rn "\['result'\]\['result'\]" pydoll/ currently shows 16 such
accesses across find_elements_mixin.py, web_element.py, tab.py and
browser/requests/request.py, most of them unguarded. That is a bigger change
than this bug needs, but it is the same failure mode waiting in the other call
sites.

There is currently no test covering a CDP error response from getProperties
(grep -rn "get_properties" tests/ returns nothing), so a regression test based
on the snippet above would lock this down.

I'm happy to open a PR with the minimal fix plus a regression test if you'd like.


Related: #449 — an earlier placeholder I opened for this same crash, which this
report supersedes. Feel free to close that one as a duplicate.

Activity

  1. thalissonvs commented on Sep 16, 2026

    @thalissonvs
    Member

    thanks for reporting @pedro-beemon, this should be fixed in the new release!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions