aminghadersohi commented on code in PR #44765:
URL: https://github.com/apache/superset/pull/44765#discussion_r4140678229


##########
superset/utils/oauth2.py:
##########
@@ -322,12 +423,11 @@ def execute_with_oauth2_retry(  # noqa: C901
             database.db_engine_spec.engine,
         )
         try:
-            access_token = refresh_oauth2_token(
+            access_token = force_refresh_oauth2_token(

Review Comment:
   Fixed in 54572820a6. `execute_with_oauth2_retry` now treats 
`OAuth2TokenRefreshError` as terminal: it increments 
`oauth2.forced_refresh.exchange_rejected`, starts the sign-in dance and 
re-raises, so it never opens the isolated session or presents the refused 
refresh token a second time. 
`test_connection_does_not_re_exchange_a_refused_refresh_token` (parametrized 
over `get_raw_connection` and `get_inspector`) starts from an expired stored 
token with the provider refusing the refresh. It asserts one exchange, the 
deletion flushed but not committed, no isolated `Session`, one 
`start_oauth2_dance` and an `OAuth2RedirectError`. Both cases fail with the 
guard removed.



##########
superset/utils/oauth2.py:
##########
@@ -268,6 +268,107 @@ def _refresh_oauth2_token_locked(  # noqa: C901
     return token.access_token
 
 
[email protected]_exception(
+    backoff.expo,
+    AcquireDistributedLockFailedException,
+    factor=0.1,
+    base=2,
+    max_tries=8,

Review Comment:
   Fixed in 54572820a6. Both decorators read `OAUTH2_LOCK_BACKOFF_FACTOR`, 
`OAUTH2_LOCK_BACKOFF_BASE` and `OAUTH2_LOCK_BACKOFF_MAX_TRIES`, so the 
expired-token and forced-refresh paths can't drift apart.



##########
tests/unit_tests/utils/oauth2_tests.py:
##########
@@ -907,3 +910,127 @@ def test_get_oauth2_redirect_uri_raises_on_runtime_error(
     )
     with pytest.raises(OAuth2Error):
         get_oauth2_redirect_uri()
+
+
+def test_force_refresh_waits_out_lock_contention(
+    mocker: MockerFixture,
+) -> None:
+    """
+    A loser of the refresh race reuses the winner's token instead of raising.
+
+    Every chart on a dashboard opening a connection with the same rejected 
token
+    races for the non-blocking refresh lock. The losers must retry until the 
winner
+    commits, then short circuit on the committed access token.
+    """
+    mocker.patch("time.sleep")  # avoid backoff delays in tests
+    db = mocker.patch("superset.utils.oauth2.db")
+    mocker.patch("superset.utils.oauth2.Session", return_value=db.session)
+    lock = mocker.patch("superset.utils.oauth2.DistributedLock")
+    lock.side_effect = [
+        LockAlreadyHeldException("Lock already taken"),
+        mocker.MagicMock(),
+    ]
+    db_engine_spec = mocker.MagicMock()
+    token = mocker.MagicMock(access_token="winning-token")  # noqa: S106
+    
db.session.query().populate_existing().filter_by().one_or_none.return_value = 
token
+
+    result = force_refresh_oauth2_token(
+        DUMMY_OAUTH2_CONFIG,
+        1,
+        2,
+        db_engine_spec,
+        rejected_access_token="rejected-token",  # noqa: S106
+    )
+
+    assert result == "winning-token"
+    assert lock.call_count == 2
+    db_engine_spec.get_oauth2_fresh_token.assert_not_called()
+
+
+def test_force_refresh_reads_committed_token_when_lock_never_frees(
+    mocker: MockerFixture,
+) -> None:
+    """Giving up on the lock still reuses a token another worker committed."""
+    mocker.patch("time.sleep")  # avoid backoff delays in tests
+    db = mocker.patch("superset.utils.oauth2.db")
+    mocker.patch("superset.utils.oauth2.Session", return_value=db.session)
+    mocker.patch(
+        "superset.utils.oauth2.DistributedLock",
+        side_effect=AcquireDistributedLockFailedException("Lock not 
available"),
+    )
+    db_engine_spec = mocker.MagicMock()
+    db.session.query().filter_by().one_or_none.return_value = mocker.MagicMock(
+        access_token="winning-token"  # noqa: S106
+    )
+
+    result = force_refresh_oauth2_token(
+        DUMMY_OAUTH2_CONFIG,
+        1,
+        2,
+        db_engine_spec,
+        rejected_access_token="rejected-token",  # noqa: S106
+    )
+
+    assert result == "winning-token"

Review Comment:
   This finding's premise is wrong. `raise_on_giveup=False` is set only on 
`get_oauth2_access_token`. The forced-refresh helper (renamed 
`_retry_forced_refresh_until_lock_available` in 54572820a6, following your 
naming suggestion) omits it, so the lock exception reaches 
`force_refresh_oauth2_token`'s `except` and `_read_refreshed_access_token` 
runs. To pin that, 54572820a6 also asserts that the lock is attempted 
`OAUTH2_LOCK_BACKOFF_MAX_TRIES` times and that the fallback's token read 
happens.



##########
tests/unit_tests/utils/oauth2_tests.py:
##########
@@ -907,3 +910,127 @@ def test_get_oauth2_redirect_uri_raises_on_runtime_error(
     )
     with pytest.raises(OAuth2Error):
         get_oauth2_redirect_uri()
+
+
+def test_force_refresh_waits_out_lock_contention(
+    mocker: MockerFixture,
+) -> None:
+    """
+    A loser of the refresh race reuses the winner's token instead of raising.
+
+    Every chart on a dashboard opening a connection with the same rejected 
token
+    races for the non-blocking refresh lock. The losers must retry until the 
winner
+    commits, then short circuit on the committed access token.
+    """
+    mocker.patch("time.sleep")  # avoid backoff delays in tests
+    db = mocker.patch("superset.utils.oauth2.db")
+    mocker.patch("superset.utils.oauth2.Session", return_value=db.session)
+    lock = mocker.patch("superset.utils.oauth2.DistributedLock")
+    lock.side_effect = [
+        LockAlreadyHeldException("Lock already taken"),
+        mocker.MagicMock(),
+    ]
+    db_engine_spec = mocker.MagicMock()
+    token = mocker.MagicMock(access_token="winning-token")  # noqa: S106
+    
db.session.query().populate_existing().filter_by().one_or_none.return_value = 
token
+
+    result = force_refresh_oauth2_token(
+        DUMMY_OAUTH2_CONFIG,
+        1,
+        2,
+        db_engine_spec,
+        rejected_access_token="rejected-token",  # noqa: S106
+    )
+
+    assert result == "winning-token"
+    assert lock.call_count == 2
+    db_engine_spec.get_oauth2_fresh_token.assert_not_called()
+
+
+def test_force_refresh_reads_committed_token_when_lock_never_frees(
+    mocker: MockerFixture,
+) -> None:
+    """Giving up on the lock still reuses a token another worker committed."""
+    mocker.patch("time.sleep")  # avoid backoff delays in tests
+    db = mocker.patch("superset.utils.oauth2.db")
+    mocker.patch("superset.utils.oauth2.Session", return_value=db.session)
+    mocker.patch(
+        "superset.utils.oauth2.DistributedLock",
+        side_effect=AcquireDistributedLockFailedException("Lock not 
available"),
+    )
+    db_engine_spec = mocker.MagicMock()
+    db.session.query().filter_by().one_or_none.return_value = mocker.MagicMock(
+        access_token="winning-token"  # noqa: S106
+    )
+
+    result = force_refresh_oauth2_token(
+        DUMMY_OAUTH2_CONFIG,
+        1,
+        2,
+        db_engine_spec,
+        rejected_access_token="rejected-token",  # noqa: S106
+    )
+
+    assert result == "winning-token"
+    db_engine_spec.get_oauth2_fresh_token.assert_not_called()
+
+
+def test_force_refresh_returns_none_when_no_one_refreshed(
+    mocker: MockerFixture,
+) -> None:
+    """With the lock held and the stored token unchanged there is nothing to 
reuse."""
+    mocker.patch("time.sleep")  # avoid backoff delays in tests
+    db = mocker.patch("superset.utils.oauth2.db")
+    mocker.patch("superset.utils.oauth2.Session", return_value=db.session)
+    mocker.patch(
+        "superset.utils.oauth2.DistributedLock",
+        side_effect=LockAlreadyHeldException("Lock already taken"),
+    )
+    db.session.query().filter_by().one_or_none.return_value = mocker.MagicMock(
+        access_token="rejected-token"  # noqa: S106
+    )
+
+    result = force_refresh_oauth2_token(
+        DUMMY_OAUTH2_CONFIG,
+        1,
+        2,
+        mocker.MagicMock(),
+        rejected_access_token="rejected-token",  # noqa: S106
+    )
+
+    assert result is None

Review Comment:
   This finding's premise is wrong. `raise_on_giveup=False` is set only on 
`get_oauth2_access_token`. The forced-refresh helper (renamed 
`_retry_forced_refresh_until_lock_available` in 54572820a6, following your 
naming suggestion) omits it, so the lock exception reaches 
`force_refresh_oauth2_token`'s `except` and `_read_refreshed_access_token` 
runs. To pin that, 54572820a6 also asserts that the lock is attempted 
`OAUTH2_LOCK_BACKOFF_MAX_TRIES` times and that the fallback's token read 
happens.



##########
tests/unit_tests/utils/oauth2_tests.py:
##########
@@ -907,3 +910,127 @@ def test_get_oauth2_redirect_uri_raises_on_runtime_error(
     )
     with pytest.raises(OAuth2Error):
         get_oauth2_redirect_uri()
+
+
+def test_force_refresh_waits_out_lock_contention(
+    mocker: MockerFixture,
+) -> None:
+    """
+    A loser of the refresh race reuses the winner's token instead of raising.
+
+    Every chart on a dashboard opening a connection with the same rejected 
token
+    races for the non-blocking refresh lock. The losers must retry until the 
winner
+    commits, then short circuit on the committed access token.
+    """
+    mocker.patch("time.sleep")  # avoid backoff delays in tests
+    db = mocker.patch("superset.utils.oauth2.db")
+    mocker.patch("superset.utils.oauth2.Session", return_value=db.session)
+    lock = mocker.patch("superset.utils.oauth2.DistributedLock")
+    lock.side_effect = [
+        LockAlreadyHeldException("Lock already taken"),
+        mocker.MagicMock(),
+    ]
+    db_engine_spec = mocker.MagicMock()
+    token = mocker.MagicMock(access_token="winning-token")  # noqa: S106
+    
db.session.query().populate_existing().filter_by().one_or_none.return_value = 
token
+
+    result = force_refresh_oauth2_token(
+        DUMMY_OAUTH2_CONFIG,
+        1,
+        2,
+        db_engine_spec,
+        rejected_access_token="rejected-token",  # noqa: S106
+    )
+
+    assert result == "winning-token"
+    assert lock.call_count == 2
+    db_engine_spec.get_oauth2_fresh_token.assert_not_called()
+
+
+def test_force_refresh_reads_committed_token_when_lock_never_frees(
+    mocker: MockerFixture,
+) -> None:
+    """Giving up on the lock still reuses a token another worker committed."""
+    mocker.patch("time.sleep")  # avoid backoff delays in tests
+    db = mocker.patch("superset.utils.oauth2.db")
+    mocker.patch("superset.utils.oauth2.Session", return_value=db.session)
+    mocker.patch(
+        "superset.utils.oauth2.DistributedLock",
+        side_effect=AcquireDistributedLockFailedException("Lock not 
available"),
+    )
+    db_engine_spec = mocker.MagicMock()
+    db.session.query().filter_by().one_or_none.return_value = mocker.MagicMock(
+        access_token="winning-token"  # noqa: S106
+    )
+
+    result = force_refresh_oauth2_token(
+        DUMMY_OAUTH2_CONFIG,
+        1,
+        2,
+        db_engine_spec,
+        rejected_access_token="rejected-token",  # noqa: S106
+    )
+
+    assert result == "winning-token"
+    db_engine_spec.get_oauth2_fresh_token.assert_not_called()
+
+
+def test_force_refresh_returns_none_when_no_one_refreshed(
+    mocker: MockerFixture,
+) -> None:
+    """With the lock held and the stored token unchanged there is nothing to 
reuse."""
+    mocker.patch("time.sleep")  # avoid backoff delays in tests
+    db = mocker.patch("superset.utils.oauth2.db")
+    mocker.patch("superset.utils.oauth2.Session", return_value=db.session)
+    mocker.patch(
+        "superset.utils.oauth2.DistributedLock",
+        side_effect=LockAlreadyHeldException("Lock already taken"),
+    )
+    db.session.query().filter_by().one_or_none.return_value = mocker.MagicMock(
+        access_token="rejected-token"  # noqa: S106
+    )
+
+    result = force_refresh_oauth2_token(
+        DUMMY_OAUTH2_CONFIG,
+        1,
+        2,
+        mocker.MagicMock(),
+        rejected_access_token="rejected-token",  # noqa: S106
+    )
+
+    assert result is None
+
+
+def test_execute_with_oauth2_retry_survives_lock_contention(
+    mocker: MockerFixture,
+) -> None:
+    """
+    A chart that loses the refresh race renders with the winner's token.
+
+    Without contention tolerance the lock failure is not an OAuth2 error, so it
+    escapes as an opaque `AcquireDistributedLockFailedException` and the chart 
fails.
+    """
+    mocker.patch("time.sleep")  # avoid backoff delays in tests
+    operation = mocker.Mock(side_effect=[RuntimeError("stale OAuth token"), 
"result"])
+    database = mocker.MagicMock()
+    database.id = 1
+    database.is_oauth2_enabled.return_value = True
+    database.db_engine_spec.needs_oauth2.return_value = True
+    database.get_oauth2_config.return_value = DUMMY_OAUTH2_CONFIG
+    mocker.patch("superset.utils.oauth2.g").user.id = 2
+    db = mocker.patch("superset.utils.oauth2.db")
+    mocker.patch("superset.utils.oauth2.Session", return_value=db.session)
+    mocker.patch(
+        "superset.utils.oauth2.DistributedLock",
+        side_effect=LockAlreadyHeldException("Lock already taken"),
+    )
+    # The rejected token is read first, then the winner's committed 
replacement.
+    db.session.query().filter_by().one_or_none.side_effect = [
+        mocker.MagicMock(access_token="rejected-token"),  # noqa: S106
+        mocker.MagicMock(access_token="winning-token"),  # noqa: S106
+    ]
+
+    assert execute_with_oauth2_retry(database, operation) == "result"
+
+    assert operation.call_count == 2
+    database.start_oauth2_dance.assert_not_called()

Review Comment:
   This finding's premise is wrong. `raise_on_giveup=False` is set only on 
`get_oauth2_access_token`. The forced-refresh helper (renamed 
`_retry_forced_refresh_until_lock_available` in 54572820a6, following your 
naming suggestion) omits it, so the lock exception reaches 
`force_refresh_oauth2_token`'s `except` and `_read_refreshed_access_token` 
runs. To pin that, 54572820a6 also asserts that the lock is attempted 
`OAUTH2_LOCK_BACKOFF_MAX_TRIES` times and that the fallback's token read 
happens.



##########
superset/utils/oauth2.py:
##########
@@ -268,6 +268,107 @@ def _refresh_oauth2_token_locked(  # noqa: C901
     return token.access_token
 
 
[email protected]_exception(
+    backoff.expo,
+    AcquireDistributedLockFailedException,
+    factor=0.1,
+    base=2,
+    max_tries=8,

Review Comment:
   The mismatch is deliberate, so I've left the window unchanged. The 30s 
`ttl_seconds` is a safety ceiling: it frees the lock if its holder dies 
mid-exchange. It isn't how long we expect an exchange to take. Waiting out the 
TTL would hold a web worker for about 30s on every losing chart request, and 
`backoff.expo` uses full jitter by default, so 12.7s is already the upper 
bound, not the typical wait. The expired-token path (`get_oauth2_access_token`) 
has used the same 12.7s window against the same 30s lock since before this PR. 
54572820a6 moves both paths onto shared constants (`OAUTH2_LOCK_BACKOFF_*`), so 
they can't diverge if we retune later. A provider that takes 20s for a token 
exchange is pathological, and the loser falls back to the sign-in dance as it 
did before, so the bounded wait seems the better trade.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to