@@ -581,11 +581,7 @@ def load_json_or_yaml(
581581 except (OSError , ValueError , yaml .scanner .ScannerError ) as exc :
582582 if exception is not None :
583583 if message is None :
584- raise exception (
585- f"Failed to load { file_type } from { string } : { exc } "
586- if is_path
587- else f"Failed to load { file_type } : { exc } "
588- )
584+ raise exception (f"Failed to load { file_type } from { string } : { exc } " if is_path else f"Failed to load { file_type } : { exc } " )
589585 repl_dict = {"exc" : str (exc ), "file_type" : file_type , "path" : string if is_path else "" }
590586 raise exception (message % repl_dict )
591587 return None
@@ -660,7 +656,7 @@ async def _log_download_error(resp, msg):
660656 log .debug ("Redirect history %s: %s; body=%s" , get_loggable_url (str (h .url )), h .status , (await h .text ())[:1000 ])
661657
662658
663- async def download_file (context , url , abs_filename , session = None , chunk_size = 128 , auth = None ):
659+ async def download_file (context , url , abs_filename , session = None , chunk_size = 128 , auth = None , expected_content_type = None ):
664660 """Download a file, async.
665661
666662 Args:
@@ -671,6 +667,16 @@ async def download_file(context, url, abs_filename, session=None, chunk_size=128
671667 None, use context.session. Defaults to None.
672668 chunk_size (int, optional): the chunk size to read from the response
673669 at a time. Default is 128.
670+ expected_content_type (str, optional): if set, raise ``DownloadError``
671+ when the server returns an ``HTML`` response and ``expected_content_type``
672+ is something other than HTML. Narrow by design — servers vary too
673+ much for a strict match — but catches the common
674+ "error page instead of the JSON/YAML/artifact we asked for" case.
675+
676+ Raises:
677+ DownloadError: on non-200 status, or an HTML response when
678+ ``expected_content_type`` was not HTML.
679+ Download404: on 404 status.
674680
675681 """
676682 session = session or context .session
@@ -683,10 +689,15 @@ async def download_file(context, url, abs_filename, session=None, chunk_size=128
683689 async with session .get (url , auth = auth ) as resp :
684690 if resp .status == 404 :
685691 await _log_download_error (resp , "404 downloading %(url)s: %(status)s; body=%(body)s" )
686- raise Download404 ("{ } status {}!" . format ( loggable_url , resp .status ) )
692+ raise Download404 (f" { loggable_url } status { resp .status } !" )
687693 elif resp .status != 200 :
688694 await _log_download_error (resp , "Failed to download %(url)s: %(status)s; body=%(body)s" )
689- raise DownloadError ("{} status {} is not 200!" .format (loggable_url , resp .status ))
695+ raise DownloadError (f"{ loggable_url } status { resp .status } is not 200!" )
696+ if expected_content_type :
697+ actual_content_type = (resp .headers .get ("Content-Type" ) or "" ).split (";" , 1 )[0 ].strip ().lower ()
698+ if actual_content_type == "text/html" and "html" not in expected_content_type .lower ():
699+ await _log_download_error (resp , "HTML response for %(url)s (expected non-HTML): %(status)s; body=%(body)s" )
700+ raise DownloadError (f"{ loggable_url } : expected Content-Type { expected_content_type !r} but got HTML; treating as an error page" )
690701 makedirs (parent_dir )
691702 with open (abs_filename , "wb" ) as fd :
692703 while True :
@@ -737,6 +748,11 @@ def get_parts_of_url_path(url):
737748async def load_json_or_yaml_from_url (context : Context , url : str , path : str , overwrite : bool = True , auth : Optional [str ] = None ) -> Dict [str , Any ]:
738749 """Retry a json/yaml file download, load it, then return its data.
739750
751+ Download and parse are combined into a single retry unit: if parsing the
752+ downloaded file fails (e.g. truncated body, an HTML error page, a Cloud
753+ Storage transcoding glitch), the cached file is deleted and the download
754+ is retried.
755+
740756 Args:
741757 context (scriptworker.context.Context): the scriptworker context.
742758 url (str): the url to download
@@ -753,20 +769,41 @@ async def load_json_or_yaml_from_url(context: Context, url: str, path: str, over
753769 """
754770 if path .endswith ("json" ):
755771 file_type = "json"
772+ expected_content_type = "application/json"
756773 else :
757774 file_type = "yaml"
775+ expected_content_type = "application/yaml"
758776
759- kwargs = {}
777+ download_kwargs = {"expected_content_type" : expected_content_type }
760778 if auth :
761- kwargs = {"auth" : auth }
762- if not overwrite or not os .path .exists (path ):
763- await retry_async (download_file , args = (context , url , path ), kwargs = kwargs , retry_exceptions = (DownloadError , aiohttp .ClientError , asyncio .TimeoutError ))
779+ download_kwargs ["auth" ] = auth
764780 loggable_url = get_loggable_url (url )
765- return load_json_or_yaml (
766- path ,
767- is_path = True ,
768- file_type = file_type ,
769- message = f"Failed to load { file_type } from { loggable_url } (cached at { path } ): %(exc)s" ,
781+
782+ async def _download_and_parse ():
783+ # Pre-existing cache semantics (despite the misleading parameter
784+ # name): ``overwrite=True`` uses an existing file when present;
785+ # ``overwrite=False`` always (re)downloads.
786+ if not overwrite or not os .path .exists (path ):
787+ await download_file (context , url , path , ** download_kwargs )
788+ try :
789+ return load_json_or_yaml (path , is_path = True , file_type = file_type )
790+ except ScriptWorkerTaskException as exc :
791+ log .warning (
792+ "Failed to parse %s from %s (cached at %s); invalidating cache and retrying: %s" ,
793+ file_type ,
794+ loggable_url ,
795+ path ,
796+ exc ,
797+ )
798+ try :
799+ os .remove (path )
800+ except OSError :
801+ pass
802+ raise DownloadError (f"parse failure for { loggable_url } : { exc } " )
803+
804+ return await retry_async (
805+ _download_and_parse ,
806+ retry_exceptions = (DownloadError , aiohttp .ClientError , asyncio .TimeoutError ),
770807 )
771808
772809
0 commit comments