Skip to content

Commit 336be1a

Browse files
committed
Retry transient failures during upstream sync
1 parent 43367bf commit 336be1a

2 files changed

Lines changed: 204 additions & 18 deletions

File tree

application/cmd/cre_main.py

Lines changed: 97 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,51 @@
3737
app = None
3838

3939

40+
def fetch_upstream_json(
41+
path: str,
42+
timeout: Optional[float] = None,
43+
max_attempts: Optional[int] = None,
44+
backoff_seconds: Optional[float] = None,
45+
) -> Dict[str, Any]:
46+
base_url = os.environ.get("CRE_UPSTREAM_API_URL", "https://opencre.org/rest/v1")
47+
timeout = timeout or float(os.environ.get("CRE_UPSTREAM_TIMEOUT_SECONDS", "30"))
48+
max_attempts = max_attempts or int(os.environ.get("CRE_UPSTREAM_MAX_ATTEMPTS", "4"))
49+
backoff_seconds = backoff_seconds or float(
50+
os.environ.get("CRE_UPSTREAM_RETRY_BACKOFF_SECONDS", "2")
51+
)
52+
url = f"{base_url}{path}"
53+
last_error: Optional[Exception] = None
54+
55+
for attempt in range(1, max_attempts + 1):
56+
try:
57+
response = requests.get(url, timeout=timeout)
58+
if response.status_code == 200:
59+
return response.json()
60+
61+
status_error = RuntimeError(
62+
f"cannot connect to upstream status code {response.status_code}"
63+
)
64+
# Retry only on transient upstream failures.
65+
if response.status_code < 500 and response.status_code != 429:
66+
raise status_error
67+
last_error = status_error
68+
except requests.exceptions.RequestException as exc:
69+
last_error = exc
70+
71+
if attempt < max_attempts:
72+
logger.warning(
73+
"upstream fetch failed for %s on attempt %s/%s, retrying",
74+
url,
75+
attempt,
76+
max_attempts,
77+
)
78+
time.sleep(backoff_seconds * attempt)
79+
80+
if last_error:
81+
raise RuntimeError(f"upstream fetch failed for {url}") from last_error
82+
raise RuntimeError(f"upstream fetch failed for {url}")
83+
84+
4085
def register_node(node: defs.Node, collection: db.Node_collection) -> db.Node:
4186
"""
4287
for each link find if either the root node or the link have a CRE,
@@ -354,6 +399,8 @@ def register_standard(
354399
):
355400
if os.environ.get("CRE_NO_GEN_EMBEDDINGS") == "1":
356401
generate_embeddings = False
402+
if os.environ.get("CRE_NO_CALCULATE_GAP_ANALYSIS"):
403+
calculate_gap_analysis = False
357404

358405
if not standard_entries:
359406
logger.warning("register_standard() called with no standard_entries")
@@ -590,15 +637,7 @@ def download_graph_from_upstream(cache: str) -> None:
590637
collection = db_connect(path=cache).with_graph()
591638

592639
def download_cre_from_upstream(creid: str):
593-
cre_response = requests.get(
594-
os.environ.get("CRE_UPSTREAM_API_URL", "https://opencre.org/rest/v1")
595-
+ f"/id/{creid}"
596-
)
597-
if cre_response.status_code != 200:
598-
raise RuntimeError(
599-
f"cannot connect to upstream status code {cre_response.status_code}"
600-
)
601-
data = cre_response.json()
640+
data = fetch_upstream_json(f"/id/{creid}")
602641
credict = data["data"]
603642
cre = defs.Document.from_dict(credict)
604643
if cre.id in imported_cres:
@@ -610,15 +649,7 @@ def download_cre_from_upstream(creid: str):
610649
if link.document.doctype == defs.Credoctypes.CRE:
611650
download_cre_from_upstream(link.document.id)
612651

613-
root_cres_response = requests.get(
614-
os.environ.get("CRE_UPSTREAM_API_URL", "https://opencre.org/rest/v1")
615-
+ "/root_cres"
616-
)
617-
if root_cres_response.status_code != 200:
618-
raise RuntimeError(
619-
f"cannot connect to upstream status code {root_cres_response.status_code}"
620-
)
621-
data = root_cres_response.json()
652+
data = fetch_upstream_json("/root_cres")
622653
for root_cre in data["data"]:
623654
cre = defs.Document.from_dict(root_cre)
624655
register_cre(cre, collection)
@@ -900,6 +931,54 @@ def run(args: argparse.Namespace) -> None: # pragma: no cover
900931
BaseParser().register_resource(
901932
secure_headers.SecureHeaders, db_connection_str=args.cache_file
902933
)
934+
if args.owasp_top10_2025_in:
935+
from application.utils.external_project_parsers.parsers import owasp_top10_2025
936+
937+
BaseParser().register_resource(
938+
owasp_top10_2025.OwaspTop10_2025, db_connection_str=args.cache_file
939+
)
940+
if args.owasp_api_top10_2023_in:
941+
from application.utils.external_project_parsers.parsers import (
942+
owasp_api_top10_2023,
943+
)
944+
945+
BaseParser().register_resource(
946+
owasp_api_top10_2023.OwaspApiTop10_2023,
947+
db_connection_str=args.cache_file,
948+
)
949+
if args.owasp_kubernetes_top10_2022_in:
950+
from application.utils.external_project_parsers.parsers import (
951+
owasp_kubernetes_top10_2022,
952+
)
953+
954+
BaseParser().register_resource(
955+
owasp_kubernetes_top10_2022.OwaspKubernetesTop10_2022,
956+
db_connection_str=args.cache_file,
957+
)
958+
if args.owasp_kubernetes_top10_2025_in:
959+
from application.utils.external_project_parsers.parsers import (
960+
owasp_kubernetes_top10_2025,
961+
)
962+
963+
BaseParser().register_resource(
964+
owasp_kubernetes_top10_2025.OwaspKubernetesTop10_2025,
965+
db_connection_str=args.cache_file,
966+
)
967+
if args.owasp_llm_top10_2025_in:
968+
from application.utils.external_project_parsers.parsers import (
969+
owasp_llm_top10_2025,
970+
)
971+
972+
BaseParser().register_resource(
973+
owasp_llm_top10_2025.OwaspLlmTop10_2025,
974+
db_connection_str=args.cache_file,
975+
)
976+
if args.owasp_aisvs_in:
977+
from application.utils.external_project_parsers.parsers import owasp_aisvs
978+
979+
BaseParser().register_resource(
980+
owasp_aisvs.OwaspAisvs, db_connection_str=args.cache_file
981+
)
903982
if args.pci_dss_4_in:
904983
from application.utils.external_project_parsers.parsers import pci_dss
905984

application/tests/cre_main_test.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from unittest import mock
88
from unittest.mock import Mock, patch
99
from rq import Queue, job
10+
import requests
11+
from rq import Queue, job
1012
from application.utils import redis
1113
from application.prompt_client import prompt_client as prompt_client
1214
from application.tests.utils import data_gen
@@ -470,6 +472,111 @@ def test_register_cre(self) -> None:
470472
],
471473
)
472474

475+
@patch("application.cmd.cre_main.time.sleep")
476+
@patch("application.cmd.cre_main.requests.get")
477+
def test_fetch_upstream_json_retries_transient_failures(
478+
self, mock_get, mock_sleep
479+
) -> None:
480+
transient_error = requests.exceptions.ConnectionError("reset by peer")
481+
success_response = Mock()
482+
success_response.status_code = 200
483+
success_response.json.return_value = {"data": []}
484+
mock_get.side_effect = [transient_error, success_response]
485+
486+
data = main.fetch_upstream_json("/root_cres")
487+
488+
self.assertEqual(data, {"data": []})
489+
self.assertEqual(mock_get.call_count, 2)
490+
mock_sleep.assert_called_once()
491+
492+
def test_parse_file(self) -> None:
493+
file: List[Dict[str, Any]] = [
494+
{
495+
"description": "Verify that approved cryptographic algorithms are used in the generation, seeding, and verification.",
496+
"doctype": defs.Credoctypes.CRE,
497+
"id": "157-573",
498+
"links": [
499+
{
500+
"type": defs.LinkTypes.LinkedTo,
501+
"document": {
502+
"doctype": defs.Credoctypes.Standard,
503+
"name": "TOP10",
504+
"section": "https://owasp.org/www-project-top-ten/2017/A5_2017-Broken_Access_Control",
505+
},
506+
},
507+
{
508+
"type": defs.LinkTypes.LinkedTo,
509+
"document": {
510+
"doctype": defs.Credoctypes.Standard,
511+
"name": "ISO 25010",
512+
"section": "Secure data storage",
513+
},
514+
},
515+
],
516+
"name": "CREDENTIALS_MANAGEMENT_CRYPTOGRAPHIC_DIRECTIVES",
517+
},
518+
{
519+
"description": "Desc",
520+
"doctype": defs.Credoctypes.CRE,
521+
"id": "141-141",
522+
"name": "name",
523+
},
524+
]
525+
expected = [
526+
defs.CRE(
527+
doctype=defs.Credoctypes.CRE,
528+
id="157-573",
529+
description="Verify that approved cryptographic algorithms are used in the generation, seeding, and verification.",
530+
name="CREDENTIALS_MANAGEMENT_CRYPTOGRAPHIC_DIRECTIVES",
531+
links=[
532+
defs.Link(
533+
document=defs.Standard(
534+
doctype=defs.Credoctypes.Standard,
535+
name="TOP10",
536+
section="https://owasp.org/www-project-top-ten/2017/A5_2017-Broken_Access_Control",
537+
),
538+
ltype=defs.LinkTypes.LinkedTo,
539+
),
540+
defs.Link(
541+
document=defs.Standard(
542+
doctype=defs.Credoctypes.Standard,
543+
name="ISO 25010",
544+
section="Secure data storage",
545+
),
546+
ltype=defs.LinkTypes.LinkedTo,
547+
),
548+
],
549+
),
550+
defs.CRE(id="141-141", description="Desc", name="name"),
551+
]
552+
with self.assertLogs("application.cmd.cre_main", level=logging.FATAL) as logs:
553+
# negative test first parse_file accepts a list of objects
554+
result = main.parse_file(
555+
filename="tests",
556+
yamldocs=[
557+
"no",
558+
"valid",
559+
"objects",
560+
"here",
561+
{
562+
"1": 2,
563+
},
564+
],
565+
scollection=self.collection,
566+
)
567+
568+
self.assertEqual(result, None)
569+
self.assertIn(
570+
"CRITICAL:application.cmd.cre_main:Malformed file tests, skipping",
571+
logs.output,
572+
)
573+
574+
self.maxDiff = None
575+
576+
res = main.parse_file(
577+
filename="tests", yamldocs=file, scollection=self.collection
578+
)
579+
self.assertCountEqual(res, expected)
473580
@patch.object(main, "db_connect")
474581
@patch.object(Queue, "enqueue_call")
475582
@patch.object(redis, "wait_for_jobs")

0 commit comments

Comments
 (0)