From 0ecfe340d75b28cb6397947d769fc8b40c306750 Mon Sep 17 00:00:00 2001 From: Martin <maarand@teklia.com> Date: Fri, 8 Jan 2021 10:46:20 +0100 Subject: [PATCH] retry all api calls in the same try-except to handle them in the same way --- arkindex_worker/git.py | 52 ++++-- tests/test_git.py | 414 +++++++++++++++++++++++++++++++---------- 2 files changed, 352 insertions(+), 114 deletions(-) diff --git a/arkindex_worker/git.py b/arkindex_worker/git.py index 463ec714..4f4f8e41 100644 --- a/arkindex_worker/git.py +++ b/arkindex_worker/git.py @@ -6,6 +6,7 @@ from datetime import datetime from pathlib import Path import gitlab +import requests import sh from arkindex_worker import logger @@ -57,23 +58,20 @@ class GitlabHelper: :param title: title of the merge request :return: was the branch successfully merged? """ - logger.info(f"Creating a merge request for {branch_name}") - mr = self.project.mergerequests.create( - { - "source_branch": branch_name, - "target_branch": self.branch, - "title": title, - } - ) + mr = None # always rebase first, because other workers might have merged already for i in range(self.max_rebase_tries): logger.info(f"Trying to merge, try nr: {i}") - mr.rebase() - rebase_success = self._wait_for_rebase_to_finish(mr.iid) - if not rebase_success: - logger.error("Rebase failed, won't be able to merge!") - return False try: + if mr is None: + mr = self._create_merge_request(branch_name, title) + + mr.rebase() + rebase_success = self._wait_for_rebase_to_finish(mr.iid) + if not rebase_success: + logger.error("Rebase failed, won't be able to merge!") + return False + mr.merge(should_remove_source_branch=self.delete_source_branch) logger.info("Merge successful") return True @@ -84,8 +82,30 @@ class GitlabHelper: else: logger.error(f"Merge was not successful: {e}") return False + except gitlab.GitlabError as e: + logger.error(f"Gitlab error: {e}") + if 400 <= e.response_code < 500: + # 4XX errors shouldn't be fixed by retrying + raise e + except requests.exceptions.ConnectionError as e: + logger.error(f"Server connection error, will wait and retry: {e}") + time.sleep(self.rebase_wait_period) + return False + def _create_merge_request(self, branch_name, title): + logger.info(f"Creating a merge request for {branch_name}") + # retry_transient_error will retry the request on 50X errors + # https://github.com/python-gitlab/python-gitlab/blob/265dbbdd37af88395574564aeb3fd0350288a18c/gitlab/__init__.py#L539 + mr = self.project.mergerequests.create( + { + "source_branch": branch_name, + "target_branch": self.branch, + "title": title, + }, + ) + return mr + def _get_merge_request(self, merge_request_id, include_rebase_in_progress=True): return self.project.mergerequests.get( merge_request_id, include_rebase_in_progress=include_rebase_in_progress @@ -315,7 +335,11 @@ class GitHelper: self._git.push("-u", "origin", "HEAD") if self.gitlab_helper: - self.gitlab_helper.merge(branch_name, f"Merge {branch_name}") + try: + self.gitlab_helper.merge(branch_name, f"Merge {branch_name}") + except Exception as e: + logger.error(f"Merge failed: {e}") + raise e else: logger.info( "No gitlab_helper defined, not trying to merge the pushed branch" diff --git a/tests/test_git.py b/tests/test_git.py index fe20ae83..4b10cddd 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -2,9 +2,39 @@ from pathlib import Path import pytest +from gitlab import GitlabCreateError, GitlabError +from requests import ConnectionError from arkindex_worker.git import GitlabHelper +PROJECT_ID = 21259233 +MERGE_REQUEST_ID = 7 +SOURCE_BRANCH = "new_branch" +TARGET_BRANCH = "master" +MR_TITLE = "merge request title" +CREATE_MR_RESPONSE_JSON = { + "id": 107, + "iid": MERGE_REQUEST_ID, + "project_id": PROJECT_ID, + "title": MR_TITLE, + "target_branch": TARGET_BRANCH, + "source_branch": SOURCE_BRANCH, + # several fields omitted +} + + +@pytest.fixture +def fake_responses(responses): + responses.add( + responses.GET, + "https://gitlab.com/api/v4/projects/balsac_exporter%2Fbalsac-exported-xmls-testing", + json={ + "id": PROJECT_ID, + # several fields omitted + }, + ) + return responses + def test_clone_done(fake_git_helper): assert not fake_git_helper.is_clone_finished @@ -91,23 +121,11 @@ def test_merge__rebase_failed(mocker): assert merqe_request.merge.call_count == 0 -def test_wait_for_rebase_to_finish(responses, fake_gitlab_helper_factory): - project_id = 21259233 - merge_request_id = 7 - - responses.add( - responses.GET, - "https://gitlab.com/api/v4/projects/balsac_exporter%2Fbalsac-exported-xmls-testing", - json={ - "id": project_id, - # several fields omitted - }, - ) +def test_wait_for_rebase_to_finish(fake_responses, fake_gitlab_helper_factory): + get_mr_url = f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests/{MERGE_REQUEST_ID}?include_rebase_in_progress=True" - get_mr_url = f"https://gitlab.com/api/v4/projects/{project_id}/merge_requests/{merge_request_id}?include_rebase_in_progress=True" - - responses.add( - responses.GET, + fake_responses.add( + fake_responses.GET, get_mr_url, json={ "rebase_in_progress": True, @@ -115,8 +133,8 @@ def test_wait_for_rebase_to_finish(responses, fake_gitlab_helper_factory): }, ) - responses.add( - responses.GET, + fake_responses.add( + fake_responses.GET, get_mr_url, json={ "rebase_in_progress": True, @@ -124,8 +142,8 @@ def test_wait_for_rebase_to_finish(responses, fake_gitlab_helper_factory): }, ) - responses.add( - responses.GET, + fake_responses.add( + fake_responses.GET, get_mr_url, json={ "rebase_in_progress": False, @@ -135,129 +153,325 @@ def test_wait_for_rebase_to_finish(responses, fake_gitlab_helper_factory): gitlab_helper = fake_gitlab_helper_factory() - success = gitlab_helper._wait_for_rebase_to_finish(merge_request_id) + success = gitlab_helper._wait_for_rebase_to_finish(MERGE_REQUEST_ID) assert success - assert len(responses.calls) == 4 + assert len(fake_responses.calls) == 4 assert gitlab_helper.is_rebase_finished -def test_merge_request(responses, fake_gitlab_helper_factory, mocker): - project_id = 21259233 - merge_request_id = 7 - source_branch = "new_branch" - target_branch = "master" - mr_title = "merge request title" +def test_wait_for_rebase_to_finish__fail_connection_error( + fake_responses, fake_gitlab_helper_factory +): + get_mr_url = f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests/{MERGE_REQUEST_ID}?include_rebase_in_progress=True" - responses.add( - responses.GET, - "https://gitlab.com/api/v4/projects/balsac_exporter%2Fbalsac-exported-xmls-testing", - json={ - "id": project_id, - # several fields omitted - }, + fake_responses.add( + fake_responses.GET, + get_mr_url, + body=ConnectionError(), ) - responses.add( - responses.POST, - f"https://gitlab.com/api/v4/projects/{project_id}/merge_requests", - json={ - "id": 107, - "iid": merge_request_id, - "project_id": project_id, - "title": mr_title, - "target_branch": target_branch, - "source_branch": source_branch, - # several fields omitted - }, + gitlab_helper = fake_gitlab_helper_factory() + + with pytest.raises(ConnectionError): + gitlab_helper._wait_for_rebase_to_finish(MERGE_REQUEST_ID) + + assert len(fake_responses.calls) == 2 + assert not gitlab_helper.is_rebase_finished + + +def test_wait_for_rebase_to_finish__fail_server_error( + fake_responses, fake_gitlab_helper_factory +): + get_mr_url = f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests/{MERGE_REQUEST_ID}?include_rebase_in_progress=True" + + fake_responses.add( + fake_responses.GET, + get_mr_url, + body="Service Unavailable", + status=503, ) - responses.add( - responses.PUT, - f"https://gitlab.com/api/v4/projects/{project_id}/merge_requests/{merge_request_id}/rebase", + gitlab_helper = fake_gitlab_helper_factory() + + with pytest.raises(GitlabError): + gitlab_helper._wait_for_rebase_to_finish(MERGE_REQUEST_ID) + + assert len(fake_responses.calls) == 2 + assert not gitlab_helper.is_rebase_finished + + +def test_merge_request(fake_responses, fake_gitlab_helper_factory, mocker): + fake_responses.add( + fake_responses.POST, + f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests", + json=CREATE_MR_RESPONSE_JSON, + ) + + fake_responses.add( + fake_responses.PUT, + f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests/{MERGE_REQUEST_ID}/rebase", json={}, ) - responses.add( - responses.PUT, - f"https://gitlab.com/api/v4/projects/{project_id}/merge_requests/{merge_request_id}/merge?should_remove_source_branch=True", + fake_responses.add( + fake_responses.PUT, + f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests/{MERGE_REQUEST_ID}/merge?should_remove_source_branch=True", json={ - "iid": merge_request_id, + "iid": MERGE_REQUEST_ID, "state": "merged", # several fields omitted }, ) - # the responses are defined in the same order as they are expected to be called - expected_http_methods = [r.method for r in responses._matches] - expected_urls = [r.url for r in responses._matches] + # the fake_responses are defined in the same order as they are expected to be called + expected_http_methods = [r.method for r in fake_responses._matches] + expected_urls = [r.url for r in fake_responses._matches] gitlab_helper = fake_gitlab_helper_factory() gitlab_helper._wait_for_rebase_to_finish = mocker.MagicMock() gitlab_helper._wait_for_rebase_to_finish.return_value = True - success = gitlab_helper.merge(source_branch, mr_title) + success = gitlab_helper.merge(SOURCE_BRANCH, MR_TITLE) assert success - assert len(responses.calls) == 4 - assert [c.request.method for c in responses.calls] == expected_http_methods - assert [c.request.url for c in responses.calls] == expected_urls + assert len(fake_responses.calls) == 4 + assert [c.request.method for c in fake_responses.calls] == expected_http_methods + assert [c.request.url for c in fake_responses.calls] == expected_urls -def test_merge_request_fail(responses, fake_gitlab_helper_factory, mocker): - project_id = 21259233 - merge_request_id = 7 - source_branch = "new_branch" - target_branch = "master" - mr_title = "merge request title" +def test_merge_request_fail(fake_responses, fake_gitlab_helper_factory, mocker): + fake_responses.add( + fake_responses.POST, + f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests", + json=CREATE_MR_RESPONSE_JSON, + ) - responses.add( - responses.GET, - "https://gitlab.com/api/v4/projects/balsac_exporter%2Fbalsac-exported-xmls-testing", + fake_responses.add( + fake_responses.PUT, + f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests/{MERGE_REQUEST_ID}/rebase", + json={}, + ) + + fake_responses.add( + fake_responses.PUT, + f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests/{MERGE_REQUEST_ID}/merge?should_remove_source_branch=True", + json={"error": "Method not allowed"}, + status=405, + ) + + # the fake_responses are defined in the same order as they are expected to be called + expected_http_methods = [r.method for r in fake_responses._matches] + expected_urls = [r.url for r in fake_responses._matches] + + gitlab_helper = fake_gitlab_helper_factory() + gitlab_helper._wait_for_rebase_to_finish = mocker.MagicMock() + gitlab_helper._wait_for_rebase_to_finish.return_value = True + + success = gitlab_helper.merge(SOURCE_BRANCH, MR_TITLE) + + assert not success + assert len(fake_responses.calls) == 4 + assert [c.request.method for c in fake_responses.calls] == expected_http_methods + assert [c.request.url for c in fake_responses.calls] == expected_urls + + +def test_merge_request__success_after_errors( + fake_responses, fake_gitlab_helper_factory +): + fake_responses.add( + fake_responses.POST, + f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests", + json=CREATE_MR_RESPONSE_JSON, + ) + + rebase_url = f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests/{MERGE_REQUEST_ID}/rebase" + + fake_responses.add( + fake_responses.PUT, + rebase_url, + json={"rebase_in_progress": True}, + ) + + get_mr_url = f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests/{MERGE_REQUEST_ID}?include_rebase_in_progress=True" + + fake_responses.add( + fake_responses.GET, + get_mr_url, + body="Service Unavailable", + status=503, + ) + + fake_responses.add( + fake_responses.PUT, + rebase_url, + json={"rebase_in_progress": True}, + ) + + fake_responses.add( + fake_responses.GET, + get_mr_url, + body=ConnectionError(), + ) + + fake_responses.add( + fake_responses.PUT, + rebase_url, + json={"rebase_in_progress": True}, + ) + + fake_responses.add( + fake_responses.GET, + get_mr_url, json={ - "id": project_id, - # several fields omitted + "rebase_in_progress": True, + "merge_error": None, }, ) - responses.add( - responses.POST, - f"https://gitlab.com/api/v4/projects/{project_id}/merge_requests", + fake_responses.add( + fake_responses.GET, + get_mr_url, json={ - "id": 107, - "iid": merge_request_id, - "project_id": project_id, - "title": mr_title, - "target_branch": target_branch, - "source_branch": source_branch, + "rebase_in_progress": False, + "merge_error": None, + }, + ) + + fake_responses.add( + fake_responses.PUT, + f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests/{MERGE_REQUEST_ID}/merge?should_remove_source_branch=True", + json={ + "iid": MERGE_REQUEST_ID, + "state": "merged", # several fields omitted }, ) - responses.add( - responses.PUT, - f"https://gitlab.com/api/v4/projects/{project_id}/merge_requests/{merge_request_id}/rebase", - json={}, + # the fake_responses are defined in the same order as they are expected to be called + expected_http_methods = [r.method for r in fake_responses._matches] + expected_urls = [r.url for r in fake_responses._matches] + + gitlab_helper = fake_gitlab_helper_factory() + + success = gitlab_helper.merge(SOURCE_BRANCH, MR_TITLE) + + assert success + assert len(fake_responses.calls) == 10 + assert [c.request.method for c in fake_responses.calls] == expected_http_methods + assert [c.request.url for c in fake_responses.calls] == expected_urls + + +def test_merge_request__fail_bad_request(fake_responses, fake_gitlab_helper_factory): + fake_responses.add( + fake_responses.POST, + f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests", + json=CREATE_MR_RESPONSE_JSON, ) - responses.add( - responses.PUT, - f"https://gitlab.com/api/v4/projects/{project_id}/merge_requests/{merge_request_id}/merge?should_remove_source_branch=True", - json={"error": "Method not allowed"}, - status=405, + rebase_url = f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests/{MERGE_REQUEST_ID}/rebase" + + fake_responses.add( + fake_responses.PUT, + rebase_url, + json={"rebase_in_progress": True}, ) - # the responses are defined in the same order as they are expected to be called - expected_http_methods = [r.method for r in responses._matches] - expected_urls = [r.url for r in responses._matches] + get_mr_url = f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests/{MERGE_REQUEST_ID}?include_rebase_in_progress=True" + + fake_responses.add( + fake_responses.GET, + get_mr_url, + body="Bad Request", + status=400, + ) + + # the fake_responses are defined in the same order as they are expected to be called + expected_http_methods = [r.method for r in fake_responses._matches] + expected_urls = [r.url for r in fake_responses._matches] gitlab_helper = fake_gitlab_helper_factory() - gitlab_helper._wait_for_rebase_to_finish = mocker.MagicMock() - gitlab_helper._wait_for_rebase_to_finish.return_value = True - success = gitlab_helper.merge(source_branch, mr_title) + with pytest.raises(GitlabError): + gitlab_helper.merge(SOURCE_BRANCH, MR_TITLE) - assert not success - assert len(responses.calls) == 4 - assert [c.request.method for c in responses.calls] == expected_http_methods - assert [c.request.url for c in responses.calls] == expected_urls + assert len(fake_responses.calls) == 4 + assert [c.request.method for c in fake_responses.calls] == expected_http_methods + assert [c.request.url for c in fake_responses.calls] == expected_urls + + +def test_create_merge_request__no_retry_5xx_error( + fake_responses, fake_gitlab_helper_factory +): + request_url = f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests" + + fake_responses.add( + fake_responses.POST, + request_url, + body="Service Unavailable", + status=503, + ) + + # the fake_responses are defined in the same order as they are expected to be called + expected_http_methods = [r.method for r in fake_responses._matches] + expected_urls = [r.url for r in fake_responses._matches] + + gitlab_helper = fake_gitlab_helper_factory() + + with pytest.raises(GitlabCreateError): + gitlab_helper.project.mergerequests.create( + { + "source_branch": "branch", + "target_branch": gitlab_helper.branch, + "title": "MR title", + } + ) + + assert len(fake_responses.calls) == 2 + assert [c.request.method for c in fake_responses.calls] == expected_http_methods + assert [c.request.url for c in fake_responses.calls] == expected_urls + + +def test_create_merge_request__retry_5xx_error( + fake_responses, fake_gitlab_helper_factory +): + request_url = f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/merge_requests?retry_transient_errors=True" + + fake_responses.add( + fake_responses.POST, + request_url, + body="Service Unavailable", + status=503, + ) + + fake_responses.add( + fake_responses.POST, + request_url, + body="Service Unavailable", + status=503, + ) + + fake_responses.add( + fake_responses.POST, + request_url, + json=CREATE_MR_RESPONSE_JSON, + ) + + # the fake_responses are defined in the same order as they are expected to be called + expected_http_methods = [r.method for r in fake_responses._matches] + expected_urls = [r.url for r in fake_responses._matches] + + gitlab_helper = fake_gitlab_helper_factory() + + gitlab_helper.project.mergerequests.create( + { + "source_branch": "branch", + "target_branch": gitlab_helper.branch, + "title": "MR title", + }, + retry_transient_errors=True, + ) + + assert len(fake_responses.calls) == 4 + assert [c.request.method for c in fake_responses.calls] == expected_http_methods + assert [c.request.url for c in fake_responses.calls] == expected_urls -- GitLab