Skip to content

Commit efd7ed6

Browse files
committed
Add unit tests and tunning of retry mechanism
1 parent 28aa757 commit efd7ed6

File tree

5 files changed

+122
-20
lines changed

5 files changed

+122
-20
lines changed

mergin/client.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
PUSH_ATTEMPTS,
2626
SYNC_CALLBACK_WAIT,
2727
ClientError,
28-
ErrorCode,
2928
LoginError,
3029
WorkspaceRole,
3130
ProjectRole,
@@ -807,7 +806,7 @@ def download_project(self, project_path, directory, version=None):
807806
def user_info(self):
808807
server_type = self.server_type()
809808
if server_type == ServerType.OLD:
810-
resp = self.get("/v1/user/" + self.username())
809+
resp = self.get(f"/v1/user/{self.username()}")
811810
else:
812811
resp = self.get("/v1/user/profile")
813812
return json.load(resp)
@@ -1526,11 +1525,11 @@ def sync_project(self, project_directory, progress_callback=None):
15261525
sleep(SYNC_CALLBACK_WAIT)
15271526
current_size = job.transferred_size
15281527
progress_callback(current_size - last_size, job) # call callback with transferred size increment
1529-
last = current_size
1528+
last_size = current_size
15301529
push_project_finalize(job)
15311530
_, has_changes = get_push_changes_batch(self, mp, job.server_resp)
15321531
except ClientError as e:
1533-
if e.sync_retry and server_conflict_attempts <= PUSH_ATTEMPTS:
1532+
if e.is_retryable_sync() and server_conflict_attempts < PUSH_ATTEMPTS - 1:
15341533
# retry on conflict, e.g. when server has changes that we do not have yet
15351534
mp.log.info(
15361535
f"Restarting sync process (conflict on server) - {server_conflict_attempts + 1}/{PUSH_ATTEMPTS}"

mergin/client_push.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from .local_changes import LocalChange, LocalChanges
2626

27-
from .common import UPLOAD_CHUNK_SIZE, ClientError, ErrorCode
27+
from .common import UPLOAD_CHUNK_ATTEMPT_WAIT, UPLOAD_CHUNK_ATTEMPTS, UPLOAD_CHUNK_SIZE, ClientError, ErrorCode
2828
from .merginproject import MerginProject
2929
from .editor import filter_changes
3030
from .utils import get_data_checksum
@@ -111,8 +111,7 @@ def upload_blocking(self):
111111
checksum_str = get_data_checksum(data)
112112

113113
self.mp.log.debug(f"Uploading {self.file_path} part={self.chunk_index}")
114-
attempts = 2
115-
for attempt in range(attempts):
114+
for attempt in range(UPLOAD_CHUNK_ATTEMPTS):
116115
try:
117116
if self.mc.server_features().get("v2_push_enabled"):
118117
# use v2 API for uploading chunks
@@ -122,8 +121,8 @@ def upload_blocking(self):
122121
self.upload_chunk(data, checksum_str)
123122
break # exit loop if upload was successful
124123
except ClientError as e:
125-
if attempt < attempts - 1:
126-
time.sleep(5)
124+
if attempt < UPLOAD_CHUNK_ATTEMPTS - 1:
125+
time.sleep(UPLOAD_CHUNK_ATTEMPT_WAIT)
127126
continue
128127
raise
129128

@@ -249,7 +248,7 @@ def create_upload_job(
249248
)
250249
push_start_resp = json.load(resp)
251250
except ClientError as err:
252-
if err.server_code not in [ErrorCode.AnotherUploadRunning.value, ErrorCode.ProjectVersionExists.value]:
251+
if not err.is_blocking_sync():
253252
mp.log.error("Error starting transaction: " + str(err))
254253
mp.log.info("--- push aborted")
255254
raise
@@ -415,10 +414,9 @@ def push_project_finalize(job: UploadJob):
415414
project_info = json.load(resp)
416415
job.server_resp = project_info
417416
except ClientError as err:
418-
if err.server_code in [ErrorCode.AnotherUploadRunning.value, ErrorCode.ProjectVersionExists.value]:
419-
err.sync_retry = True
420-
else:
421-
job.mc.upload_chunks_cache.clear() # clear the upload chunks cache, as we are getting fatal from server
417+
if not err.is_retryable_sync():
418+
# clear the upload chunks cache, as we are getting fatal from server
419+
job.mc.upload_chunks_cache.clear()
422420
job.mp.log.error("--- push finish failed! " + str(err))
423421
raise err
424422
elif with_upload_of_files:

mergin/common.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,22 @@
66
# there is an upper limit for chunk size on server, ideally should be requested from there once implemented
77
UPLOAD_CHUNK_SIZE = 10 * 1024 * 1024
88

9+
# number of attempts to upload a chunk
10+
UPLOAD_CHUNK_ATTEMPTS = 2
11+
12+
# seconds to wait between attempts of uploading a chunk
13+
UPLOAD_CHUNK_ATTEMPT_WAIT = 5
14+
915
# size of the log file part to send (if file is larger only this size from end will be sent)
1016
MAX_LOG_FILE_SIZE_TO_SEND = 5 * 1024 * 1024
1117

1218
# number of attempts to push changes (in case of network issues etc)
13-
PUSH_ATTEMPTS = 12
19+
PUSH_ATTEMPTS = 10
1420

15-
# seconds to wait between attempts
21+
# seconds to wait between attempts to push changes
1622
PUSH_ATTEMPT_WAIT = 5
1723

18-
# seconds to wait between sync callback calls
24+
# seconds to wait between sync callback calls
1925
SYNC_CALLBACK_WAIT = 0.01
2026

2127
# default URL for submitting logs
@@ -33,7 +39,7 @@ class ErrorCode(Enum):
3339

3440

3541
class ClientError(Exception):
36-
def __init__(self, detail, url=None, server_code=None, server_response=None, http_error=None, http_method=None):
42+
def __init__(self, detail: str, url=None, server_code=None, server_response=None, http_error=None, http_method=None):
3743
self.detail = detail
3844
self.url = url
3945
self.http_error = http_error
@@ -43,8 +49,6 @@ def __init__(self, detail, url=None, server_code=None, server_response=None, htt
4349
self.server_response = server_response
4450

4551
self.extra = None
46-
# Param to mark error as candidate for retry sync process
47-
self.sync_retry = False
4852

4953
def __str__(self):
5054
string_res = f"Detail: {self.detail}\n"
@@ -60,6 +64,35 @@ def __str__(self):
6064
string_res += f"{self.extra}\n"
6165
return string_res
6266

67+
def is_rate_limit(self) -> bool:
68+
"""Check if error is rate limit error based on server code"""
69+
return self.http_error == 429
70+
71+
def is_blocking_sync(self) -> bool:
72+
"""
73+
Check if error is blocking sync based on server code.
74+
Blocking sync means, that the sync is blocked by another user in server.
75+
"""
76+
return self.server_code in [
77+
ErrorCode.AnotherUploadRunning.value,
78+
ErrorCode.ProjectVersionExists.value,
79+
]
80+
81+
def is_retryable_sync(self) -> bool:
82+
# Check if error is retryable based on server code
83+
if self.is_blocking_sync() or self.is_rate_limit():
84+
return True
85+
86+
if (
87+
self.http_error
88+
and self.detail
89+
and self.http_error == 400
90+
and ("Another process" in self.detail or "Version mismatch" in self.detail)
91+
):
92+
return True
93+
94+
return False
95+
6396

6497
class LoginError(Exception):
6598
pass

mergin/test/test_client.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import pytz
1212
import sqlite3
1313
import glob
14+
from unittest.mock import patch, Mock
1415

1516
from .. import InvalidProject
1617
from ..client import (
@@ -3157,3 +3158,28 @@ def test_client_project_sync(mc):
31573158
os.path.join(project_dir_2, "base.gpkg"),
31583159
)
31593160
mc.sync_project(project_dir_2)
3161+
3162+
3163+
def test_client_project_sync_retry(mc):
3164+
test_project = "test_client_project_sync_retry"
3165+
project = API_USER + "/" + test_project
3166+
project_dir = os.path.join(TMP_DIR, test_project)
3167+
cleanup(mc, project, [project_dir])
3168+
mc.create_project(test_project)
3169+
mc.download_project(project, project_dir)
3170+
shutil.copy(os.path.join(TEST_DATA_DIR, "test.txt"), project_dir)
3171+
with patch("mergin.client.push_project_finalize", autospec=True) as mock_push_project_finalize:
3172+
mock_push_project_finalize.side_effect = ClientError("test error")
3173+
with pytest.raises(ClientError, match="test error"):
3174+
mc.sync_project(project_dir)
3175+
3176+
with patch("mergin.client.push_project_finalize") as mock_push_project_finalize, patch(
3177+
"mergin.client.PUSH_ATTEMPTS", 2
3178+
):
3179+
mock_push_project_finalize.side_effect = ClientError(
3180+
detail="",
3181+
server_code=ErrorCode.AnotherUploadRunning.value,
3182+
)
3183+
with pytest.raises(ClientError):
3184+
mc.sync_project(project_dir)
3185+
assert mock_push_project_finalize.call_count == 2

mergin/test/test_common.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from ..common import ClientError, ErrorCode
2+
3+
def test_client_error_is_blocked_sync():
4+
"""Test the is_blocked_sync method of ClientError."""
5+
error = ClientError(detail="", server_code=None)
6+
assert error.is_blocking_sync() is False
7+
error.server_code = ErrorCode.ProjectsLimitHit.value
8+
assert error.is_blocking_sync() is False
9+
10+
error.server_code = ErrorCode.AnotherUploadRunning.value
11+
assert error.is_blocking_sync() is True
12+
error.server_code = ErrorCode.ProjectVersionExists.value
13+
assert error.is_blocking_sync() is True
14+
15+
def test_client_error_is_rate_limit():
16+
"""Test the is_rate_limit method of ClientError."""
17+
error = ClientError(detail="", http_error=None)
18+
assert error.is_rate_limit() is False
19+
error.http_error = 500
20+
assert error.is_rate_limit() is False
21+
error.http_error = 429
22+
assert error.is_rate_limit() is True
23+
24+
def test_client_error_is_retryable_sync():
25+
"""Test the is_retryable_sync method of ClientError."""
26+
error = ClientError(detail="", server_code=None, http_error=None)
27+
assert error.is_retryable_sync() is False
28+
29+
error.server_code = ErrorCode.ProjectsLimitHit.value
30+
assert error.is_retryable_sync() is False
31+
error.server_code = ErrorCode.AnotherUploadRunning.value
32+
assert error.is_retryable_sync() is True
33+
34+
error.server_code = None
35+
error.http_error = 400
36+
error.detail = "Some other error"
37+
assert error.is_retryable_sync() is False
38+
error.detail = "Another process"
39+
assert error.is_retryable_sync() is True
40+
error.detail = "Version mismatch"
41+
assert error.is_retryable_sync() is True
42+
43+
error.http_error = 500
44+
assert error.is_retryable_sync() is False
45+
error.http_error = 429
46+
assert error.is_retryable_sync() is True

0 commit comments

Comments
 (0)