Skip to content

Commit 1675194

Browse files
authored
feat: retry on 429 with Retry-After support in VulnerableCode session (#550)
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent eb426ef commit 1675194

2 files changed

Lines changed: 91 additions & 0 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# DejaCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: AGPL-3.0-only
5+
# See https://github.com/aboutcode-org/dejacode for support or download.
6+
# See https://aboutcode.org for more information about AboutCode FOSS projects.
7+
#
8+
9+
import io
10+
from unittest import TestCase
11+
from unittest.mock import MagicMock
12+
from unittest.mock import patch
13+
14+
import urllib3.connectionpool
15+
from requests.adapters import HTTPAdapter
16+
from urllib3.response import HTTPResponse
17+
from urllib3.util.retry import Retry
18+
19+
from dejacode_toolkit.vulnerablecode import VulnerableCode
20+
21+
22+
def make_dataspace(vulnerablecode_url="https://public.vulnerablecode.io"):
23+
config = MagicMock()
24+
config.vulnerablecode_url = vulnerablecode_url
25+
dataspace = MagicMock()
26+
dataspace.get_configuration.return_value = config
27+
return dataspace
28+
29+
30+
def make_urllib3_response(status, body=b"", headers=None):
31+
return HTTPResponse(
32+
body=io.BytesIO(body),
33+
headers=headers or {},
34+
status=status,
35+
preload_content=False,
36+
)
37+
38+
39+
class VulnerableCodenTestCase(TestCase):
40+
def setUp(self):
41+
self.service = VulnerableCode(make_dataspace())
42+
43+
def test_get_session_retry_configuration(self):
44+
session = self.service.get_session()
45+
adapter = session.get_adapter("https://example.com")
46+
47+
self.assertIsInstance(adapter, HTTPAdapter)
48+
self.assertIsInstance(adapter.max_retries, Retry)
49+
self.assertEqual(adapter.max_retries.total, 3)
50+
self.assertIn(429, adapter.max_retries.status_forcelist)
51+
self.assertIn("POST", adapter.max_retries.allowed_methods)
52+
self.assertTrue(adapter.max_retries.respect_retry_after_header)
53+
54+
@patch.object(urllib3.connectionpool.HTTPSConnectionPool, "_get_conn")
55+
@patch.object(urllib3.connectionpool.HTTPSConnectionPool, "_make_request")
56+
def test_bulk_search_by_purl_retries_on_429(self, mock_make_request, mock_get_conn):
57+
# _get_conn is mocked to prevent any real TCP connection attempt.
58+
mock_get_conn.return_value = MagicMock()
59+
60+
# _make_request is called once per attempt inside urllib3's retry loop,
61+
# so two side_effect values simulate: 429 on first try, 200 on retry.
62+
mock_make_request.side_effect = [
63+
make_urllib3_response(
64+
429,
65+
headers={"Retry-After": "0"},
66+
),
67+
make_urllib3_response(
68+
200,
69+
body=b'{"count": 1, "results": [{"purl": "pkg:pypi/django@4.2"}]}',
70+
headers={"Content-Type": "application/json"},
71+
),
72+
]
73+
74+
result = self.service.bulk_search_by_purl(purls=["pkg:pypi/django@4.2"])
75+
76+
self.assertEqual(mock_make_request.call_count, 2)
77+
self.assertIsNotNone(result)
78+
self.assertEqual(result["count"], 1)

dejacode_toolkit/vulnerablecode.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
from django.core.cache import caches
1010

11+
from requests.adapters import HTTPAdapter
12+
from urllib3.util.retry import Retry
13+
1114
from dejacode_toolkit import BaseService
1215
from dejacode_toolkit import get_settings
1316
from dejacode_toolkit import logger
@@ -24,8 +27,18 @@ class VulnerableCode(BaseService):
2427
user_agent = get_settings("VULNERABLECODE_USER_AGENT", default="VCIO_API_AGENT")
2528

2629
def get_session(self):
30+
"""Add the required User-Agent header and automatic 429 retry with Retry-After support."""
2731
session = super().get_session()
2832
session.headers.update({"User-Agent": self.user_agent})
33+
retry = Retry(
34+
total=3,
35+
status_forcelist=[429],
36+
allowed_methods={"GET", "POST"},
37+
respect_retry_after_header=True,
38+
)
39+
adapter = HTTPAdapter(max_retries=retry)
40+
session.mount("https://", adapter)
41+
session.mount("http://", adapter)
2942
return session
3043

3144
def get_vulnerabilities_by_purl(

0 commit comments

Comments
 (0)