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