Repository: python-discord/bot
Issue: ValueError: invalid literal for int() with base 10: ''

Sentry Issue: [BOT-1MX](https://sentry.io/organizations/python-discord/issues/2640090939/?referrer=github_integration)

```
ValueError: invalid literal for int() with base 10: ''
  File "bot/exts/info/doc/_inventory_parser.py", line 107, in fetch_inventory
    inventory = await _fetch_inventory(url)
  File "bot/exts/info/doc/_inventory_parser.py", line 83, in _fetch_inventory
    inventory_version = int(inventory_header[-1:])

An unexpected error has occurred during fetching of https://ipython.readthedocs.io/en/stable/index.html; trying again (3/3).
```

Caused by running

 ```
!docs setdoc ipython https://ipython.readthedocs.io/en/stable/index.html https://ipython.readthedocs.io/en/stable/objects.inv
```

Changed files:
bot/exts/info/doc/_cog.py, bot/exts/info/doc/_inventory_parser.py, bot/converters.py

diff --git a/bot/converters.py b/bot/converters.py
index 8b089083..c96e2c98 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -235,11 +235,16 @@ class Inventory(Converter):
     async def convert(ctx: Context, url: str) -> t.Tuple[str, _inventory_parser.InventoryDict]:
         """Convert url to Intersphinx inventory URL."""
         await ctx.trigger_typing()
-        if (inventory := await _inventory_parser.fetch_inventory(url)) is None:
-            raise BadArgument(
-                f"Failed to fetch inventory file after {_inventory_parser.FAILED_REQUEST_ATTEMPTS} attempts."
-            )
-        return url, inventory
+        try:
+            inventory = await _inventory_parser.fetch_inventory(url)
+        except _inventory_parser.InvalidHeaderError:
+            raise BadArgument("Unable to parse inventory because of invalid header, check if URL is correct.")
+        else:
+            if inventory is None:
+                raise BadArgument(
+                    f"Failed to fetch inventory file after {_inventory_parser.FAILED_REQUEST_ATTEMPTS} attempts."
+                )
+            return url, inventory
 
 
 class Snowflake(IDConverter):
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index 1624c50f..e7710db2 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -22,7 +22,7 @@ from bot.utils.lock import SharedEvent, lock
 from bot.utils.messages import send_denial, wait_for_deletion
 from bot.utils.scheduling import Scheduler
 from . import NAMESPACE, PRIORITY_PACKAGES, _batch_parser, doc_cache
-from ._inventory_parser import InventoryDict, fetch_inventory
+from ._inventory_parser import InvalidHeaderError, InventoryDict, fetch_inventory
 
 log = logging.getLogger(__name__)
 
@@ -137,7 +137,12 @@ class DocCog(commands.Cog):
         The first attempt is rescheduled to execute in `FETCH_RESCHEDULE_DELAY.first` minutes, the subsequent attempts
         in `FETCH_RESCHEDULE_DELAY.repeated` minutes.
         """
-        package = await fetch_inventory(inventory_url)
+        try:
+            package = await fetch_inventory(inventory_url)
+        except InvalidHeaderError as e:
+            # Do not reschedule if the header is invalid, as the request went through but the contents are invalid.
+            log.warning(f"Invalid inventory header at {inventory_url}. Reason: {e}")
+            return
 
         if not package:
             if api_package_name in self.inventory_scheduler:
diff --git a/bot/exts/info/doc/_inventory_parser.py b/bot/exts/info/doc/_inventory_parser.py
index 80d5841a..61924d07 100644
--- a/bot/exts/info/doc/_inventory_parser.py
+++ b/bot/exts/info/doc/_inventory_parser.py
@@ -16,6 +16,10 @@ _V2_LINE_RE = re.compile(r'(?x)(.+?)\\s+(\\S*:\\S*)\\s+(-?\\d+)\\s+?(\\S*)\\s+(.*)')
 InventoryDict = DefaultDict[str, List[Tuple[str, str]]]
 
 
+class InvalidHeaderError(Exception):
+    """Raised when an inventory file has an invalid header."""
+
+
 class ZlibStreamReader:
     """Class used for decoding zlib data of a stream line by line."""
 
@@ -80,19 +84,25 @@ async def _fetch_inventory(url: str) -> InventoryDict:
         stream = response.content
 
         inventory_header = (await stream.readline()).decode().rstrip()
-        inventory_version = int(inventory_header[-1:])
-        await stream.readline()  # skip project name
-        await stream.readline()  # skip project version
+        try:
+            inventory_version = int(inventory_header[-1:])
+        except ValueError:
+            raise InvalidHeaderError("Unable to convert inventory version header.")
+
+        has_project_header = (await stream.readline()).startswith(b"# Project")
+        has_version_header = (await stream.readline()).startswith(b"# Version")
+        if not (has_project_header and has_version_header):
+            raise InvalidHeaderError("Inventory missing project or version header.")
 
         if inventory_version == 1:
             return await _load_v1(stream)
 
         elif inventory_version == 2:
             if b"zlib" not in await stream.readline():
-                raise ValueError(f"Invalid inventory file at url {url}.")
+                raise InvalidHeaderError("'zlib' not found in header of compressed inventory.")
             return await _load_v2(stream)
 
-        raise ValueError(f"Invalid inventory file at url {url}.")
+        raise InvalidHeaderError("Incompatible inventory version.")
 
 
 async def fetch_inventory(url: str) -> Optional[InventoryDict]:
@@ -115,6 +125,8 @@ async def fetch_inventory(url: str) -> Optional[InventoryDict]:
                 f"Failed to get inventory from {url}; "
                 f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
             )
+        except InvalidHeaderError:
+            raise
         except Exception:
             log.exception(
                 f"An unexpected error has occurred during fetching of {url}; "