Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

on_reply_download_failure() assumes exceptions have a uuid attribute #2274

Closed
cfm opened this issue Oct 29, 2024 · 9 comments · Fixed by #2275 or #2276
Closed

on_reply_download_failure() assumes exceptions have a uuid attribute #2274

cfm opened this issue Oct 29, 2024 · 9 comments · Fixed by #2275 or #2276
Assignees
Labels
bug Something isn't working
Milestone

Comments

@cfm
Copy link
Member

cfm commented Oct 29, 2024

Description

After #2231, a NoResultFound exception can cause a further AttributeError in securedrop_client.logic.Controller.on_reply_download_failure().

Steps to Reproduce

Discovered in test runs of f6bd2c9 without this padding—

# Wait for a full sync to give us a stable source list.
qtbot.wait(TIME_SYNC)

—suggesting that it's a race in downloading something that's been deleted. After #2252, reproduce with:

cfm@kintsugi client % git diff tests/functional/test_delete_sources.py
diff --git a/client/tests/functional/test_delete_sources.py b/client/tests/functional/test_delete_sources.py
index cebf3ba5..d211e4d9 100644
--- a/client/tests/functional/test_delete_sources.py
+++ b/client/tests/functional/test_delete_sources.py
@@ -21,7 +21,6 @@ from tests.conftest import (
 
 
 @pytest.mark.parametrize("num_to_delete", [1, 3])
-@flaky
 def test_delete_sources(functional_test_logged_in_context, qtbot, mocker, num_to_delete):
     """
     Check that sources selected for deletion are first confirmed and then
@@ -29,9 +28,6 @@ def test_delete_sources(functional_test_logged_in_context, qtbot, mocker, num_to
     """
     gui, controller = functional_test_logged_in_context
 
-    # Wait for a full sync to give us a stable source list.
-    qtbot.wait(TIME_SYNC)
-
     def check_for_sources():
         """Check that we have enough sources to work with."""
         assert len(list(gui.main_view.source_list.source_items.keys())) >= num_to_delete
cfm@kintsugi client % rm tests/functional/data/test_delete_sources.yml 
cfm@kintsugi client % TESTS=tests/functional/test_delete_sources.py make test

Expected Behavior

Either on_reply_download_failure() should only take a DownloadException, or it should not assume all exceptions it handles have a uuid attribute.

Actual Behavior

ERROR tests/functional/test_delete_sources.py::test_delete_sources[3] - Failed: SETUP ERROR: Exceptions caught in Qt event loop:
________________________________________________________________________________
Traceback (most recent call last):
  File "/Users/cfm/Desktop/securedrop-client/client/securedrop_client/logic.py", line 919, in on_reply_download_failure
    reply = storage.get_reply(self.session, exception.uuid)
                                            ^^^^^^^^^^^^^^
AttributeError: 'NoResultFound' object has no attribute 'uuid'
________________________________________________________________________________
Traceback (most recent call last):
  File "/Users/cfm/Desktop/securedrop-client/client/securedrop_client/logic.py", line 919, in on_reply_download_failure
    reply = storage.get_reply(self.session, exception.uuid)
                                            ^^^^^^^^^^^^^^
AttributeError: 'NoResultFound' object has no attribute 'uuid'
@cfm cfm added the bug Something isn't working label Oct 29, 2024
@cfm cfm added this to the 0.14.0 milestone Oct 29, 2024
@legoktm legoktm self-assigned this Oct 29, 2024
@legoktm
Copy link
Member

legoktm commented Oct 29, 2024

I haven't been able to reproduce this nor obviously trace what's happening...I suppose you don't have the full traceback anymore?

@legoktm
Copy link
Member

legoktm commented Oct 29, 2024

Previously:

        try:
            reply = storage.get_reply(self.session, exception.uuid)
            self.reply_download_failed.emit(reply.source.uuid, reply.uuid, str(reply))
        except Exception as e:

Now:

        reply = storage.get_reply(self.session, exception.uuid)
        if reply:
            self.reply_download_failed.emit(reply.source.uuid, reply.uuid, str(reply))
        else:

So any old AttributeError would've been caught.

@legoktm
Copy link
Member

legoktm commented Oct 29, 2024

Interestingly on_file_download_failure types it as just Exception while on_message_download_failure/on_reply_download_failure are specifically DownloadException.

legoktm added a commit that referenced this issue Oct 29, 2024
Exceptions can be other types, so we should use a wider type and
isinstance checks. This brings `on_message_download_failure` and
`on_reply_download_failure` in line with `on_file_download_failure`.

This is mostly a bandage over the regression seen in #2274 and doesn't
really substitute for the lack of more coordinated error handling.

Fixes #2274.
@legoktm legoktm moved this to In Progress in SecureDrop dev cycle Oct 29, 2024
@cfm
Copy link
Member Author

cfm commented Oct 29, 2024

Oops, sorry, @legoktm: see updated steps to reproduce after #2252 above. This particular failure mode under pytest doesn't give a full traceback without further instrumentation.

@legoktm
Copy link
Member

legoktm commented Oct 29, 2024

thanks, I was able to reproduce now! Digging in a bit.

@legoktm
Copy link
Member

legoktm commented Oct 29, 2024

So here's the underlying traceback of the exception that then fails in the download handling:

Traceback (most recent call last):
  File "/home/user/github/freedomofpress/securedrop-client/client/securedrop_client/queue.py", line 230, in process
    self.current_job._do_call_api(self.api_client, session)
  File "/home/user/github/freedomofpress/securedrop-client/client/securedrop_client/api_jobs/base.py", line 77, in _do_call_api
    result = self.call_api(api_client, session)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/user/github/freedomofpress/securedrop-client/client/securedrop_client/api_jobs/downloads.py", line 123, in call_api
    db_object = self.get_db_object(session)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/user/github/freedomofpress/securedrop-client/client/securedrop_client/api_jobs/downloads.py", line 245, in get_db_object
    return session.query(Reply).filter_by(uuid=self.uuid).one()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/user/.cache/pypoetry/virtualenvs/securedrop-client-OfHWkjU1-py3.11/lib64/python3.11/site-packages/sqlalchemy/orm/query.py", line 3282, in one
    raise orm_exc.NoResultFound("No row was found for one()")
sqlalchemy.orm.exc.NoResultFound: No row was found for one()

(obtained with traceback.print_exception(exception))

@legoktm
Copy link
Member

legoktm commented Oct 29, 2024

I'm going to submit two PRs, first is the one I already posted, which broadens exception handling because there are still other exceptions that can be raised that could trigger this error in the exception handling.

The second will change DownloadJob.get_db_object() to use one_or_none() just like #2231 and then handle the None case with an explicit DownloadException.

legoktm added a commit that referenced this issue Oct 29, 2024
Our error handling expects that failures from DownloadJobs raise
DownloadExceptions, so let's do that.

This is roughly the same concept as a9f0590, just in a different
part of the code.

Fixes #2274.
legoktm added a commit that referenced this issue Oct 29, 2024
Our error handling expects that failures from DownloadJobs raise
DownloadExceptions, so let's do that.

This is roughly the same concept as a9f0590, just in a different
part of the code. The exception handling has to exist in each class
(instead of `one_or_none()`) because we need to know the corresponding
type to pass to DownloadException.

Fixes #2274.
@deeplow
Copy link
Contributor

deeplow commented Nov 1, 2024

I wasn't able to reproduce this. Following the reproduction steps, I get:

ERROR tests/functional/test_delete_sources.py::test_delete_sources[1] - pytestqt.exceptions.TimeoutError: waitUntil timed out in 10000 milliseconds
ERROR tests/functional/test_delete_sources.py::test_delete_sources[3] - pytestqt.exceptions.TimeoutError: waitUntil timed out in 10000 milliseconds

@github-project-automation github-project-automation bot moved this from In Progress to Done in SecureDrop dev cycle Nov 1, 2024
@rocodes
Copy link
Contributor

rocodes commented Nov 6, 2024

ugh, you even asked me @legoktm if it was okay to stop catching Exception and I said yes. Sorry, but also, thanks for figuring this out because this is better.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
Status: Done
4 participants