From ec0b8e651df57133743d3cf0d3a426e76af964ea Mon Sep 17 00:00:00 2001 From: Dan Lenski Date: Tue, 6 Oct 2020 21:01:57 -0700 Subject: [PATCH] gracefully handle duplicate and slow activity uploads upload_activity: handle poll-and-wait for uploads that require long processing time --- garminexport/garminclient.py | 65 ++++++++++++++++++++++++++++++++---- garminexport/retryer.py | 4 +-- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/garminexport/garminclient.py b/garminexport/garminclient.py index 9ff6b6e..0c354f7 100755 --- a/garminexport/garminclient.py +++ b/garminexport/garminclient.py @@ -10,6 +10,7 @@ import os.path import re import sys import zipfile +from datetime import timedelta, datetime from builtins import range from functools import wraps from io import BytesIO @@ -18,6 +19,8 @@ import dateutil import dateutil.parser import requests +from garminexport.retryer import Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy + # # Note: For more detailed information about the API services # used by this module, log in to your Garmin Connect account @@ -354,16 +357,47 @@ class GarminClient(object): # and cannot be exported to fit return orig_file if fmt == 'fit' else None + @require_session + def _poll_upload_completion(self, uuid, creation_date): + """Poll for completion of an upload. If Garmin connect returns + HTTP status 202 ("Accepted") after initial upload, then we must poll + until the upload has either succeeded or failed. Raises an + :class:`Exception` if the upload has failed. + + :param uuid: uploadUuid returned on initial upload. + :type uuid: str + :param creation_date: creationDate returned from initial upload (e.g. + "2020-01-01 12:34:56.789 GMT") + :type creation_date: str + :returns: Garmin's internalId for the newly-created activity, or + :obj:`None` if upload is still processing. + :rtype: int + """ + response = self.session.get("https://connect.garmin.com/modern/proxy/activity-service/activity/status/{}/{}?_={}".format( + creation_date[:10], uuid.replace("-",""), int(datetime.now().timestamp()*1000)), headers={"nk": "NT"}) + if response.status_code == 201 and response.headers["location"]: + # location should be https://connectapi.garmin.com/activity-service/activity/ACTIVITY_ID + return int(response.headers["location"].split("/")[-1]) + elif response.status_code == 202: + return None # still processing + else: + response.raise_for_status() + @require_session def upload_activity(self, file, format=None, name=None, description=None, activity_type=None, private=None): """Upload a GPX, TCX, or FIT file for an activity. :param file: Path or open file - :param format: File format (gpx, tcx, or fit); guessed from filename if None + :param format: File format (gpx, tcx, or fit); guessed from filename if :obj:`None` + :type format: str :param name: Optional name for the activity on Garmin Connect + :type name: str :param description: Optional description for the activity on Garmin Connect + :type description: str :param activity_type: Optional activityType key (lowercase: e.g. running, cycling) + :type activityType: str :param private: If true, then activity will be set as private. + :type private: bool :returns: ID of the newly-uploaded activity :rtype: int """ @@ -392,15 +426,34 @@ class GarminClient(object): raise Exception(u"failed to upload {} for activity: {}\n{}".format( format, response.status_code, response.text)) - if len(j["failures"]) or len(j["successes"]) < 1: - raise Exception(u"failed to upload {} for activity: {}\n{}".format( - format, response.status_code, j["failures"])) + # single activity, immediate success + if len(j["successes"]) == 1 and len(j["failures"]) == 0: + activity_id = j["successes"][0]["internalId"] - if len(j["successes"]) > 1: + # duplicate of existing activity + elif len(j["failures"]) == 1 and len(j["successes"]) == 0 and response.status_code == 409: + log.info(u"duplicate activity uploaded, continuing") + activity_id = j["failures"][0]["internalId"] + + # need to poll until success/failure + elif len(j["failures"]) == 0 and len(j["successes"]) == 0 and response.status_code == 202: + retryer = Retryer( + returnval_predicate=bool, + delay_strategy=ExponentialBackoffDelayStrategy(initial_delay=timedelta(seconds=1)), + stop_strategy=MaxRetriesStopStrategy(6), # wait for up to 64 seconds (2**6) + error_strategy=None + ) + activity_id = retryer.call(self._poll_upload_completion, j["uploadUuid"]["uuid"], j["creationDate"]) + + # don't know how to handle multiple activities + elif len(j["successes"]) > 1: raise Exception(u"uploading {} resulted in multiple activities ({})".format( format, len(j["successes"]))) - activity_id = j["successes"][0]["internalId"] + # all other errors + else: + raise Exception(u"failed to upload {} for activity: {}\n{}".format( + format, response.status_code, j["failures"])) # add optional fields data = {} diff --git a/garminexport/retryer.py b/garminexport/retryer.py index 73454cf..41a2c6e 100644 --- a/garminexport/retryer.py +++ b/garminexport/retryer.py @@ -168,7 +168,7 @@ class Retryer(object): :param stop_strategy: determines when we are to stop retrying. :type stop_strategy: :class:`StopStrategy` :param error_strategy: determines which errors (if any) to suppress - when raised by the called function. + when raised by the called function (`None` to stop on any error). :type error_strategy: :class:`ErrorStrategy` """ self.returnval_predicate = returnval_predicate @@ -203,7 +203,7 @@ class Retryer(object): return returnval log.debug('{%s}: failed: return value: %s', name, returnval) except Exception as e: - if not self.error_strategy.should_suppress(e): + if self.error_strategy is None or not self.error_strategy.should_suppress(e): raise e log.debug('{%s}: failed: error: %s', name, e) elapsed_time = datetime.now() - start