Skip to content

Commit 2e4abf7

Browse files
authored
Merge branch 'main' into mb/update-dependencies-20260527
2 parents b60f292 + b5abbd5 commit 2e4abf7

2 files changed

Lines changed: 264 additions & 7 deletions

File tree

deepnote_toolkit/sql/sql_execution.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from google.cloud import bigquery
1919
from packaging.version import parse as parse_version
2020
from pydantic import BaseModel
21+
from requests.adapters import HTTPAdapter, Retry
2122
from sqlalchemy.engine import URL, Connection, create_engine, make_url
2223
from sqlalchemy.exc import ResourceClosedError
2324

@@ -263,13 +264,28 @@ class ExecuteSqlError(Exception):
263264
)
264265

265266

266-
def _generate_temporary_credentials(integration_id):
267+
def _create_retry_session() -> requests.Session:
268+
"""Create a requests session with retry on 5xx for POST requests."""
269+
session = requests.Session()
270+
retries = Retry(
271+
total=3,
272+
backoff_factor=0.5,
273+
status_forcelist=[500, 502, 503, 504],
274+
allowed_methods=["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "TRACE"],
275+
)
276+
session.mount("http://", HTTPAdapter(max_retries=retries))
277+
session.mount("https://", HTTPAdapter(max_retries=retries))
278+
return session
279+
280+
281+
def _generate_temporary_credentials(integration_id) -> tuple[str, str]:
267282
url = get_absolute_userpod_api_url(f"integrations/credentials/{integration_id}")
268283

269284
# Add project credentials in detached mode
270285
headers = get_project_auth_headers()
271286

272-
response = requests.post(url, timeout=10, headers=headers)
287+
session = _create_retry_session()
288+
response = session.post(url, timeout=10, headers=headers)
273289

274290
response.raise_for_status()
275291

@@ -291,7 +307,8 @@ def _get_federated_auth_credentials(
291307
headers = get_project_auth_headers()
292308
headers["UserPodAuthContextToken"] = user_pod_auth_context_token
293309

294-
response = requests.post(url, timeout=10, headers=headers)
310+
session = _create_retry_session()
311+
response = session.post(url, timeout=10, headers=headers)
295312

296313
response.raise_for_status()
297314

tests/unit/test_sql_execution.py

Lines changed: 244 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -592,9 +592,9 @@ def test_all_dataframes_serialize_to_parquet(self, key, df):
592592
class TestFederatedAuth(unittest.TestCase):
593593
@mock.patch("deepnote_toolkit.sql.sql_execution.get_project_auth_headers")
594594
@mock.patch("deepnote_toolkit.sql.sql_execution.get_absolute_userpod_api_url")
595-
@mock.patch("deepnote_toolkit.sql.sql_execution.requests.post")
595+
@mock.patch("deepnote_toolkit.sql.sql_execution._create_retry_session")
596596
def test_get_federated_auth_credentials_returns_validated_response(
597-
self, mock_post, mock_get_url, mock_get_headers
597+
self, mock_create_session, mock_get_url, mock_get_headers
598598
):
599599
"""Test that _get_federated_auth_credentials properly validates and returns response data."""
600600
from deepnote_toolkit.sql.sql_execution import _get_federated_auth_credentials
@@ -603,12 +603,14 @@ def test_get_federated_auth_credentials_returns_validated_response(
603603
mock_get_url.return_value = "https://api.example.com/integrations/federated-auth-token/test-integration-id"
604604
mock_get_headers.return_value = {"Authorization": "Bearer project-token"}
605605

606+
mock_session = mock.Mock()
606607
mock_response = mock.Mock()
607608
mock_response.json.return_value = {
608609
"integrationType": "trino",
609610
"accessToken": "test-access-token-123",
610611
}
611-
mock_post.return_value = mock_response
612+
mock_session.post.return_value = mock_response
613+
mock_create_session.return_value = mock_session
612614

613615
# Call the function
614616
result = _get_federated_auth_credentials(
@@ -621,7 +623,7 @@ def test_get_federated_auth_credentials_returns_validated_response(
621623
)
622624

623625
# Verify headers include both project auth and user pod auth context token
624-
mock_post.assert_called_once_with(
626+
mock_session.post.assert_called_once_with(
625627
"https://api.example.com/integrations/federated-auth-token/test-integration-id",
626628
timeout=10,
627629
headers={
@@ -1019,3 +1021,241 @@ def test_databricks_connector_dialect_alias_is_registered(self):
10191021

10201022
self.assertEqual(url.drivername, "databricks+connector")
10211023
self.assertIsNotNone(dialect_cls)
1024+
1025+
1026+
class TestCreateRetrySession(unittest.TestCase):
1027+
"""Tests that exercise the real urllib3 retry loop by mocking at the
1028+
connection level (``HTTPConnectionPool._make_request``) rather than
1029+
replacing ``_create_retry_session``. This lets the ``Retry`` adapter
1030+
actually fire retries on 5xx responses.
1031+
"""
1032+
1033+
def test_create_retry_session_configuration(self):
1034+
"""Verify the retry session is wired with the expected parameters."""
1035+
from deepnote_toolkit.sql.sql_execution import _create_retry_session
1036+
1037+
session = _create_retry_session()
1038+
1039+
for prefix in ("http://", "https://"):
1040+
adapter = session.get_adapter(f"{prefix}example.com")
1041+
retry = adapter.max_retries
1042+
1043+
self.assertEqual(retry.total, 3)
1044+
self.assertEqual(retry.backoff_factor, 0.5)
1045+
self.assertEqual(set(retry.status_forcelist), {500, 502, 503, 504})
1046+
self.assertIn("POST", retry.allowed_methods)
1047+
1048+
# -- _generate_temporary_credentials ------------------------------------
1049+
1050+
@mock.patch("urllib3.util.retry.Retry.sleep", return_value=None)
1051+
@mock.patch("urllib3.connectionpool.HTTPConnectionPool._make_request")
1052+
@mock.patch("deepnote_toolkit.sql.sql_execution.get_project_auth_headers")
1053+
@mock.patch("deepnote_toolkit.sql.sql_execution.get_absolute_userpod_api_url")
1054+
def test_generate_credentials_retries_on_5xx_then_succeeds(
1055+
self,
1056+
mock_get_url,
1057+
mock_get_headers,
1058+
mock_make_request,
1059+
mock_retry_sleep,
1060+
):
1061+
"""Two 5xx failures followed by a 200 - the retry loop must
1062+
transparently retry and ultimately return valid credentials."""
1063+
from urllib3 import HTTPResponse as Urllib3Response
1064+
1065+
from deepnote_toolkit.sql.sql_execution import _generate_temporary_credentials
1066+
1067+
mock_get_url.return_value = (
1068+
"https://api.example.com/integrations/credentials/test-id"
1069+
)
1070+
mock_get_headers.return_value = {"Authorization": "Bearer token"}
1071+
1072+
success_body = json.dumps({"username": "user", "password": "pass"}).encode()
1073+
mock_make_request.side_effect = [
1074+
Urllib3Response(
1075+
body=io.BytesIO(b"Internal Server Error"),
1076+
status=500,
1077+
headers={},
1078+
preload_content=False,
1079+
),
1080+
Urllib3Response(
1081+
body=io.BytesIO(b"Bad Gateway"),
1082+
status=502,
1083+
headers={},
1084+
preload_content=False,
1085+
),
1086+
Urllib3Response(
1087+
body=io.BytesIO(success_body),
1088+
status=200,
1089+
headers={"Content-Type": "application/json"},
1090+
preload_content=False,
1091+
),
1092+
]
1093+
1094+
result = _generate_temporary_credentials("test-id")
1095+
1096+
self.assertEqual(result, ("user", "pass"))
1097+
self.assertEqual(mock_make_request.call_count, 3)
1098+
self.assertEqual(mock_retry_sleep.call_count, 2)
1099+
1100+
@mock.patch("urllib3.util.retry.Retry.sleep", return_value=None)
1101+
@mock.patch("urllib3.connectionpool.HTTPConnectionPool._make_request")
1102+
@mock.patch("deepnote_toolkit.sql.sql_execution.get_project_auth_headers")
1103+
@mock.patch("deepnote_toolkit.sql.sql_execution.get_absolute_userpod_api_url")
1104+
def test_generate_credentials_exhausts_retries_on_persistent_5xx(
1105+
self,
1106+
mock_get_url,
1107+
mock_get_headers,
1108+
mock_make_request,
1109+
mock_retry_sleep,
1110+
):
1111+
"""All 4 attempts (1 original + 3 retries) return 500 -
1112+
must raise ``RetryError``."""
1113+
import requests
1114+
from urllib3 import HTTPResponse as Urllib3Response
1115+
1116+
from deepnote_toolkit.sql.sql_execution import _generate_temporary_credentials
1117+
1118+
mock_get_url.return_value = (
1119+
"https://api.example.com/integrations/credentials/test-id"
1120+
)
1121+
mock_get_headers.return_value = {"Authorization": "Bearer token"}
1122+
1123+
mock_make_request.side_effect = [
1124+
Urllib3Response(
1125+
body=io.BytesIO(b"Server Error"),
1126+
status=500,
1127+
headers={},
1128+
preload_content=False,
1129+
)
1130+
for _ in range(4)
1131+
]
1132+
1133+
with self.assertRaises(requests.exceptions.RetryError):
1134+
_generate_temporary_credentials("test-id")
1135+
1136+
self.assertEqual(mock_make_request.call_count, 4)
1137+
self.assertEqual(mock_retry_sleep.call_count, 3)
1138+
1139+
@mock.patch("urllib3.util.retry.Retry.sleep", return_value=None)
1140+
@mock.patch("urllib3.connectionpool.HTTPConnectionPool._make_request")
1141+
@mock.patch("deepnote_toolkit.sql.sql_execution.get_project_auth_headers")
1142+
@mock.patch("deepnote_toolkit.sql.sql_execution.get_absolute_userpod_api_url")
1143+
def test_generate_credentials_no_retry_on_4xx(
1144+
self,
1145+
mock_get_url,
1146+
mock_get_headers,
1147+
mock_make_request,
1148+
mock_retry_sleep,
1149+
):
1150+
"""A 400 is not in the retry status list - must fail immediately
1151+
without retrying."""
1152+
import requests
1153+
from urllib3 import HTTPResponse as Urllib3Response
1154+
1155+
from deepnote_toolkit.sql.sql_execution import _generate_temporary_credentials
1156+
1157+
mock_get_url.return_value = (
1158+
"https://api.example.com/integrations/credentials/test-id"
1159+
)
1160+
mock_get_headers.return_value = {"Authorization": "Bearer token"}
1161+
1162+
mock_make_request.side_effect = [
1163+
Urllib3Response(
1164+
body=io.BytesIO(b"Bad Request"),
1165+
status=400,
1166+
headers={},
1167+
preload_content=False,
1168+
),
1169+
]
1170+
1171+
with self.assertRaises(requests.exceptions.HTTPError):
1172+
_generate_temporary_credentials("test-id")
1173+
1174+
self.assertEqual(mock_make_request.call_count, 1)
1175+
mock_retry_sleep.assert_not_called()
1176+
1177+
# -- _get_federated_auth_credentials ------------------------------------
1178+
1179+
@mock.patch("urllib3.util.retry.Retry.sleep", return_value=None)
1180+
@mock.patch("urllib3.connectionpool.HTTPConnectionPool._make_request")
1181+
@mock.patch("deepnote_toolkit.sql.sql_execution.get_project_auth_headers")
1182+
@mock.patch("deepnote_toolkit.sql.sql_execution.get_absolute_userpod_api_url")
1183+
def test_federated_auth_retries_on_5xx_then_succeeds(
1184+
self,
1185+
mock_get_url,
1186+
mock_get_headers,
1187+
mock_make_request,
1188+
mock_retry_sleep,
1189+
):
1190+
"""A 503 followed by a 200 - retry loop must recover and return
1191+
valid ``FederatedAuthResponseData``."""
1192+
from urllib3 import HTTPResponse as Urllib3Response
1193+
1194+
from deepnote_toolkit.sql.sql_execution import _get_federated_auth_credentials
1195+
1196+
mock_get_url.return_value = (
1197+
"https://api.example.com/integrations/federated-auth-token/test-id"
1198+
)
1199+
mock_get_headers.return_value = {"Authorization": "Bearer token"}
1200+
1201+
success_body = json.dumps(
1202+
{"integrationType": "trino", "accessToken": "test-token"}
1203+
).encode()
1204+
mock_make_request.side_effect = [
1205+
Urllib3Response(
1206+
body=io.BytesIO(b"Service Unavailable"),
1207+
status=503,
1208+
headers={},
1209+
preload_content=False,
1210+
),
1211+
Urllib3Response(
1212+
body=io.BytesIO(success_body),
1213+
status=200,
1214+
headers={"Content-Type": "application/json"},
1215+
preload_content=False,
1216+
),
1217+
]
1218+
1219+
result = _get_federated_auth_credentials("test-id", "auth-context-token")
1220+
1221+
self.assertEqual(result.integrationType, "trino")
1222+
self.assertEqual(result.accessToken, "test-token")
1223+
self.assertEqual(mock_make_request.call_count, 2)
1224+
self.assertEqual(mock_retry_sleep.call_count, 1)
1225+
1226+
@mock.patch("urllib3.util.retry.Retry.sleep", return_value=None)
1227+
@mock.patch("urllib3.connectionpool.HTTPConnectionPool._make_request")
1228+
@mock.patch("deepnote_toolkit.sql.sql_execution.get_project_auth_headers")
1229+
@mock.patch("deepnote_toolkit.sql.sql_execution.get_absolute_userpod_api_url")
1230+
def test_federated_auth_exhausts_retries_on_persistent_5xx(
1231+
self,
1232+
mock_get_url,
1233+
mock_get_headers,
1234+
mock_make_request,
1235+
mock_retry_sleep,
1236+
):
1237+
"""All 4 attempts return 504 - must raise ``RetryError``."""
1238+
import requests
1239+
from urllib3 import HTTPResponse as Urllib3Response
1240+
1241+
from deepnote_toolkit.sql.sql_execution import _get_federated_auth_credentials
1242+
1243+
mock_get_url.return_value = (
1244+
"https://api.example.com/integrations/federated-auth-token/test-id"
1245+
)
1246+
mock_get_headers.return_value = {"Authorization": "Bearer token"}
1247+
1248+
mock_make_request.side_effect = [
1249+
Urllib3Response(
1250+
body=io.BytesIO(b"Gateway Timeout"),
1251+
status=504,
1252+
headers={},
1253+
preload_content=False,
1254+
)
1255+
for _ in range(4)
1256+
]
1257+
1258+
with self.assertRaises(requests.exceptions.RetryError):
1259+
_get_federated_auth_credentials("test-id", "auth-context-token")
1260+
1261+
self.assertEqual(mock_make_request.call_count, 4)

0 commit comments

Comments
 (0)