gracefully handle duplicate and slow activity uploads
upload_activity: handle poll-and-wait for uploads that require long processing time
This commit is contained in:
		
							parent
							
								
									577f5ec3c1
								
							
						
					
					
						commit
						ec0b8e651d
					
				@ -10,6 +10,7 @@ import os.path
 | 
				
			|||||||
import re
 | 
					import re
 | 
				
			||||||
import sys
 | 
					import sys
 | 
				
			||||||
import zipfile
 | 
					import zipfile
 | 
				
			||||||
 | 
					from datetime import timedelta, datetime
 | 
				
			||||||
from builtins import range
 | 
					from builtins import range
 | 
				
			||||||
from functools import wraps
 | 
					from functools import wraps
 | 
				
			||||||
from io import BytesIO
 | 
					from io import BytesIO
 | 
				
			||||||
@ -18,6 +19,8 @@ import dateutil
 | 
				
			|||||||
import dateutil.parser
 | 
					import dateutil.parser
 | 
				
			||||||
import requests
 | 
					import requests
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from garminexport.retryer import Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
# Note: For more detailed information about the API services
 | 
					# Note: For more detailed information about the API services
 | 
				
			||||||
# used by this module, log in to your Garmin Connect account
 | 
					# used by this module, log in to your Garmin Connect account
 | 
				
			||||||
@ -354,16 +357,47 @@ class GarminClient(object):
 | 
				
			|||||||
        # and cannot be exported to fit
 | 
					        # and cannot be exported to fit
 | 
				
			||||||
        return orig_file if fmt == 'fit' else None
 | 
					        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
 | 
					    @require_session
 | 
				
			||||||
    def upload_activity(self, file, format=None, name=None, description=None, activity_type=None, private=None):
 | 
					    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.
 | 
					        """Upload a GPX, TCX, or FIT file for an activity.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param file: Path or open file
 | 
					        :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
 | 
					        :param name: Optional name for the activity on Garmin Connect
 | 
				
			||||||
 | 
					        :type name: str
 | 
				
			||||||
        :param description: Optional description for the activity on Garmin Connect
 | 
					        :param description: Optional description for the activity on Garmin Connect
 | 
				
			||||||
 | 
					        :type description: str
 | 
				
			||||||
        :param activity_type: Optional activityType key (lowercase: e.g. running, cycling)
 | 
					        :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.
 | 
					        :param private: If true, then activity will be set as private.
 | 
				
			||||||
 | 
					        :type private: bool
 | 
				
			||||||
        :returns: ID of the newly-uploaded activity
 | 
					        :returns: ID of the newly-uploaded activity
 | 
				
			||||||
        :rtype: int
 | 
					        :rtype: int
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
@ -392,15 +426,34 @@ class GarminClient(object):
 | 
				
			|||||||
            raise Exception(u"failed to upload {} for activity: {}\n{}".format(
 | 
					            raise Exception(u"failed to upload {} for activity: {}\n{}".format(
 | 
				
			||||||
                format, response.status_code, response.text))
 | 
					                format, response.status_code, response.text))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if len(j["failures"]) or len(j["successes"]) < 1:
 | 
					        # single activity, immediate success
 | 
				
			||||||
            raise Exception(u"failed to upload {} for activity: {}\n{}".format(
 | 
					        if len(j["successes"]) == 1 and len(j["failures"]) == 0:
 | 
				
			||||||
                format, response.status_code, j["failures"]))
 | 
					            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(
 | 
					            raise Exception(u"uploading {} resulted in multiple activities ({})".format(
 | 
				
			||||||
                format, len(j["successes"])))
 | 
					                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
 | 
					        # add optional fields
 | 
				
			||||||
        data = {}
 | 
					        data = {}
 | 
				
			||||||
 | 
				
			|||||||
@ -168,7 +168,7 @@ class Retryer(object):
 | 
				
			|||||||
        :param stop_strategy: determines when we are to stop retrying.
 | 
					        :param stop_strategy: determines when we are to stop retrying.
 | 
				
			||||||
        :type stop_strategy: :class:`StopStrategy`
 | 
					        :type stop_strategy: :class:`StopStrategy`
 | 
				
			||||||
        :param error_strategy: determines which errors (if any) to suppress
 | 
					        :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`
 | 
					        :type error_strategy: :class:`ErrorStrategy`
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.returnval_predicate = returnval_predicate
 | 
					        self.returnval_predicate = returnval_predicate
 | 
				
			||||||
@ -203,7 +203,7 @@ class Retryer(object):
 | 
				
			|||||||
                    return returnval
 | 
					                    return returnval
 | 
				
			||||||
                log.debug('{%s}: failed: return value: %s', name, returnval)
 | 
					                log.debug('{%s}: failed: return value: %s', name, returnval)
 | 
				
			||||||
            except Exception as e:
 | 
					            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
 | 
					                    raise e
 | 
				
			||||||
                log.debug('{%s}: failed: error: %s', name, e)
 | 
					                log.debug('{%s}: failed: error: %s', name, e)
 | 
				
			||||||
            elapsed_time = datetime.now() - start
 | 
					            elapsed_time = datetime.now() - start
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user