Repository: python-discord/bot
Issue: Handle out of range year in Duration converter

Sentry Issue: [BOT-39](https://sentry.io/organizations/python-discord/issues/1611103057/?referrer=github_integration)

As documented [here](https://docs.python.org/3/library/datetime.html#datetime.date), it has to be `MINYEAR <= year <= MAXYEAR` which is effectively `1 <= year <= 9999`

```
ValueError: year 104039 is out of range
  File "discord/ext/commands/core.py", line 425, in _actual_conversion
    ret = await instance.convert(ctx, argument)
  File "bot/converters.py", line 220, in convert
    return now + delta
  File "dateutil/relativedelta.py", line 405, in __radd__
    return self.__add__(other)
  File "dateutil/relativedelta.py", line 387, in __add__
    ret = (other.replace(**repl)
```

Changed files:
tests/bot/test_converters.py, bot/converters.py

diff --git a/bot/converters.py b/bot/converters.py
index 72c46fdf..4deb59f8 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -217,7 +217,10 @@ class Duration(Converter):
         delta = relativedelta(**duration_dict)
         now = datetime.utcnow()
 
-        return now + delta
+        try:
+            return now + delta
+        except ValueError:
+            raise BadArgument(f"`{duration}` results in a datetime outside the supported range.")
 
 
 class ISODateTime(Converter):
diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py
index ca8cb682..c42111f3 100644
--- a/tests/bot/test_converters.py
+++ b/tests/bot/test_converters.py
@@ -1,5 +1,5 @@
-import asyncio
 import datetime
+import re
 import unittest
 from unittest.mock import MagicMock, patch
 
@@ -16,7 +16,7 @@ from bot.converters import (
 )
 
 
-class ConverterTests(unittest.TestCase):
+class ConverterTests(unittest.IsolatedAsyncioTestCase):
     """Tests our custom argument converters."""
 
     @classmethod
@@ -26,7 +26,7 @@ class ConverterTests(unittest.TestCase):
 
         cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00')
 
-    def test_tag_content_converter_for_valid(self):
+    async def test_tag_content_converter_for_valid(self):
         """TagContentConverter should return correct values for valid input."""
         test_values = (
             ('hello', 'hello'),
@@ -35,10 +35,10 @@ class ConverterTests(unittest.TestCase):
 
         for content, expected_conversion in test_values:
             with self.subTest(content=content, expected_conversion=expected_conversion):
-                conversion = asyncio.run(TagContentConverter.convert(self.context, content))
+                conversion = await TagContentConverter.convert(self.context, content)
                 self.assertEqual(conversion, expected_conversion)
 
-    def test_tag_content_converter_for_invalid(self):
+    async def test_tag_content_converter_for_invalid(self):
         """TagContentConverter should raise the proper exception for invalid input."""
         test_values = (
             ('', "Tag contents should not be empty, or filled with whitespace."),
@@ -47,10 +47,10 @@ class ConverterTests(unittest.TestCase):
 
         for value, exception_message in test_values:
             with self.subTest(tag_content=value, exception_message=exception_message):
-                with self.assertRaises(BadArgument, msg=exception_message):
-                    asyncio.run(TagContentConverter.convert(self.context, value))
+                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+                    await TagContentConverter.convert(self.context, value)
 
-    def test_tag_name_converter_for_valid(self):
+    async def test_tag_name_converter_for_valid(self):
         """TagNameConverter should return the correct values for valid tag names."""
         test_values = (
             ('tracebacks', 'tracebacks'),
@@ -60,10 +60,10 @@ class ConverterTests(unittest.TestCase):
 
         for name, expected_conversion in test_values:
             with self.subTest(name=name, expected_conversion=expected_conversion):
-                conversion = asyncio.run(TagNameConverter.convert(self.context, name))
+                conversion = await TagNameConverter.convert(self.context, name)
                 self.assertEqual(conversion, expected_conversion)
 
-    def test_tag_name_converter_for_invalid(self):
+    async def test_tag_name_converter_for_invalid(self):
         """TagNameConverter should raise the correct exception for invalid tag names."""
         test_values = (
             ('👋', "Don't be ridiculous, you can't use that character!"),
@@ -75,29 +75,29 @@ class ConverterTests(unittest.TestCase):
 
         for invalid_name, exception_message in test_values:
             with self.subTest(invalid_name=invalid_name, exception_message=exception_message):
-                with self.assertRaises(BadArgument, msg=exception_message):
-                    asyncio.run(TagNameConverter.convert(self.context, invalid_name))
+                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+                    await TagNameConverter.convert(self.context, invalid_name)
 
-    def test_valid_python_identifier_for_valid(self):
+    async def test_valid_python_identifier_for_valid(self):
         """ValidPythonIdentifier returns valid identifiers unchanged."""
         test_values = ('foo', 'lemon')
 
         for name in test_values:
             with self.subTest(identifier=name):
-                conversion = asyncio.run(ValidPythonIdentifier.convert(self.context, name))
+                conversion = await ValidPythonIdentifier.convert(self.context, name)
                 self.assertEqual(name, conversion)
 
-    def test_valid_python_identifier_for_invalid(self):
+    async def test_valid_python_identifier_for_invalid(self):
         """ValidPythonIdentifier raises the proper exception for invalid identifiers."""
         test_values = ('nested.stuff', '#####')
 
         for name in test_values:
             with self.subTest(identifier=name):
                 exception_message = f'`{name}` is not a valid Python identifier'
-                with self.assertRaises(BadArgument, msg=exception_message):
-                    asyncio.run(ValidPythonIdentifier.convert(self.context, name))
+                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+                    await ValidPythonIdentifier.convert(self.context, name)
 
-    def test_duration_converter_for_valid(self):
+    async def test_duration_converter_for_valid(self):
         """Duration returns the correct `datetime` for valid duration strings."""
         test_values = (
             # Simple duration strings
@@ -159,35 +159,35 @@ class ConverterTests(unittest.TestCase):
                 mock_datetime.utcnow.return_value = self.fixed_utc_now
 
                 with self.subTest(duration=duration, duration_dict=duration_dict):
-                    converted_datetime = asyncio.run(converter.convert(self.context, duration))
+                    converted_datetime = await converter.convert(self.context, duration)
                     self.assertEqual(converted_datetime, expected_datetime)
 
-    def test_duration_converter_for_invalid(self):
+    async def test_duration_converter_for_invalid(self):
         """Duration raises the right exception for invalid duration strings."""
         test_values = (
             # Units in wrong order
-            ('1d1w'),
-            ('1s1y'),
+            '1d1w',
+            '1s1y',
 
             # Duplicated units
-            ('1 year 2 years'),
-            ('1 M 10 minutes'),
+            '1 year 2 years',
+            '1 M 10 minutes',
 
             # Unknown substrings
-            ('1MVes'),
-            ('1y3breads'),
+            '1MVes',
+            '1y3breads',
 
             # Missing amount
-            ('ym'),
+            'ym',
 
             # Incorrect whitespace
-            (" 1y"),
-            ("1S "),
-            ("1y  1m"),
+            " 1y",
+            "1S ",
+            "1y  1m",
 
             # Garbage
-            ('Guido van Rossum'),
-            ('lemon lemon lemon lemon lemon lemon lemon'),
+            'Guido van Rossum',
+            'lemon lemon lemon lemon lemon lemon lemon',
         )
 
         converter = Duration()
@@ -195,10 +195,21 @@ class ConverterTests(unittest.TestCase):
         for invalid_duration in test_values:
             with self.subTest(invalid_duration=invalid_duration):
                 exception_message = f'`{invalid_duration}` is not a valid duration string.'
-                with self.assertRaises(BadArgument, msg=exception_message):
-                    asyncio.run(converter.convert(self.context, invalid_duration))
+                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+                    await converter.convert(self.context, invalid_duration)
 
-    def test_isodatetime_converter_for_valid(self):
+    @patch("bot.converters.datetime")
+    async def test_duration_converter_out_of_range(self, mock_datetime):
+        """Duration converter should raise BadArgument if datetime raises a ValueError."""
+        mock_datetime.__add__.side_effect = ValueError
+        mock_datetime.utcnow.return_value = mock_datetime
+
+        duration = f"{datetime.MAXYEAR}y"
+        exception_message = f"`{duration}` results in a datetime outside the supported range."
+        with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+            await Duration().convert(self.context, duration)
+
+    async def test_isodatetime_converter_for_valid(self):
         """ISODateTime converter returns correct datetime for valid datetime string."""
         test_values = (
             # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ`
@@ -243,37 +254,37 @@ class ConverterTests(unittest.TestCase):
 
         for datetime_string, expected_dt in test_values:
             with self.subTest(datetime_string=datetime_string, expected_dt=expected_dt):
-                converted_dt = asyncio.run(converter.convert(self.context, datetime_string))
+                converted_dt = await converter.convert(self.context, datetime_string)
                 self.assertIsNone(converted_dt.tzinfo)
                 self.assertEqual(converted_dt, expected_dt)
 
-    def test_isodatetime_converter_for_invalid(self):
+    async def test_isodatetime_converter_for_invalid(self):
         """ISODateTime converter raises the correct exception for invalid datetime strings."""
         test_values = (
             # Make sure it doesn't interfere with the Duration converter
-            ('1Y'),
-            ('1d'),
-            ('1H'),
+            '1Y',
+            '1d',
+            '1H',
 
             # Check if it fails when only providing the optional time part
-            ('10:10:10'),
-            ('10:00'),
+            '10:10:10',
+            '10:00',
 
             # Invalid date format
-            ('19-01-01'),
+            '19-01-01',
 
             # Other non-valid strings
-            ('fisk the tag master'),
+            'fisk the tag master',
         )
 
         converter = ISODateTime()
         for datetime_string in test_values:
             with self.subTest(datetime_string=datetime_string):
                 exception_message = f"`{datetime_string}` is not a valid ISO-8601 datetime string"
-                with self.assertRaises(BadArgument, msg=exception_message):
-                    asyncio.run(converter.convert(self.context, datetime_string))
+                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+                    await converter.convert(self.context, datetime_string)
 
-    def test_hush_duration_converter_for_valid(self):
+    async def test_hush_duration_converter_for_valid(self):
         """HushDurationConverter returns correct value for minutes duration or `"forever"` strings."""
         test_values = (
             ("0", 0),
@@ -286,10 +297,10 @@ class ConverterTests(unittest.TestCase):
         converter = HushDurationConverter()
         for minutes_string, expected_minutes in test_values:
             with self.subTest(minutes_string=minutes_string, expected_minutes=expected_minutes):
-                converted = asyncio.run(converter.convert(self.context, minutes_string))
+                converted = await converter.convert(self.context, minutes_string)
                 self.assertEqual(expected_minutes, converted)
 
-    def test_hush_duration_converter_for_invalid(self):
+    async def test_hush_duration_converter_for_invalid(self):
         """HushDurationConverter raises correct exception for invalid minutes duration strings."""
         test_values = (
             ("16", "Duration must be at most 15 minutes."),
@@ -299,5 +310,5 @@ class ConverterTests(unittest.TestCase):
         converter = HushDurationConverter()
         for invalid_minutes_string, exception_message in test_values:
             with self.subTest(invalid_minutes_string=invalid_minutes_string, exception_message=exception_message):
-                with self.assertRaisesRegex(BadArgument, exception_message):
-                    asyncio.run(converter.convert(self.context, invalid_minutes_string))
+                with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):
+                    await converter.convert(self.context, invalid_minutes_string)