Repository: getmoto/moto

Issue:
Route53 ChangeResourceRecordSets should only handle 100 ResourceRecords at the time
Would it be reasonable for Moto to implement the same limitations imposed by the actual AWS API?

For example, Route53 ChangeResourceRecordSets Requests [cannot contain more than 1,000 ResourceRecord elements](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests-changeresourcerecordsets).

Enforcing this in Moto would help find issues early in applications where logic needs to be added to divide changes into smaller batches, instead of learning the hard way late in the deployment cycle.

Or is this outside the intended scope of moto?


Discussion:
Limitations like that are definitely in scope! Will mark this as an enhancement for people to pick up.

Resolving patch:
diff --git a/moto/route53/exceptions.py b/moto/route53/exceptions.py
--- a/moto/route53/exceptions.py
+++ b/moto/route53/exceptions.py
@@ -72,3 +72,12 @@ class QueryLoggingConfigAlreadyExists(Route53ClientError):
     def __init__(self):
         message = "A query logging configuration already exists for this hosted zone"
         super().__init__("QueryLoggingConfigAlreadyExists", message)
+
+
+class InvalidChangeBatch(Route53ClientError):
+
+    code = 400
+
+    def __init__(self):
+        message = "Number of records limit of 1000 exceeded."
+        super().__init__("InvalidChangeBatch", message)
diff --git a/moto/route53/responses.py b/moto/route53/responses.py
--- a/moto/route53/responses.py
+++ b/moto/route53/responses.py
@@ -6,7 +6,7 @@
 import xmltodict
 
 from moto.core.responses import BaseResponse
-from moto.route53.exceptions import Route53ClientError
+from moto.route53.exceptions import Route53ClientError, InvalidChangeBatch
 from moto.route53.models import route53_backend
 
 XMLNS = "https://route53.amazonaws.com/doc/2013-04-01/"
@@ -112,6 +112,23 @@ def rrset_response(self, request, full_url, headers):
                     ]["Change"]
                 ]
 
+            # Enforce quotas https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests-changeresourcerecordsets
+            #  - A request cannot contain more than 1,000 ResourceRecord elements. When the value of the Action element is UPSERT, each ResourceRecord element is counted twice.
+            effective_rr_count = 0
+            for value in change_list:
+                record_set = value["ResourceRecordSet"]
+                if (
+                    "ResourceRecords" not in record_set
+                    or not record_set["ResourceRecords"]
+                ):
+                    continue
+                resource_records = list(record_set["ResourceRecords"].values())[0]
+                effective_rr_count += len(resource_records)
+                if value["Action"] == "UPSERT":
+                    effective_rr_count += len(resource_records)
+            if effective_rr_count > 1000:
+                raise InvalidChangeBatch
+
             error_msg = route53_backend.change_resource_record_sets(zoneid, change_list)
             if error_msg:
                 return 400, headers, error_msg


Test patch:
diff --git a/tests/test_route53/test_route53.py b/tests/test_route53/test_route53.py
--- a/tests/test_route53/test_route53.py
+++ b/tests/test_route53/test_route53.py
@@ -1472,3 +1472,124 @@ def test_get_change():
 
     response["ChangeInfo"]["Id"].should.equal(change_id)
     response["ChangeInfo"]["Status"].should.equal("INSYNC")
+
+
+@mock_route53
+def test_change_resource_record_sets_records_limit():
+    conn = boto3.client("route53", region_name="us-east-1")
+    conn.create_hosted_zone(
+        Name="db.",
+        CallerReference=str(hash("foo")),
+        HostedZoneConfig=dict(PrivateZone=True, Comment="db"),
+    )
+
+    zones = conn.list_hosted_zones_by_name(DNSName="db.")
+    len(zones["HostedZones"]).should.equal(1)
+    zones["HostedZones"][0]["Name"].should.equal("db.")
+    hosted_zone_id = zones["HostedZones"][0]["Id"]
+
+    # Changes creating exactly 1,000 resource records.
+    changes = []
+    for ci in range(4):
+        resourcerecords = []
+        for rri in range(250):
+            resourcerecords.append({"Value": "127.0.0.%d" % (rri)})
+        changes.append(
+            {
+                "Action": "CREATE",
+                "ResourceRecordSet": {
+                    "Name": "foo%d.db." % (ci),
+                    "Type": "A",
+                    "TTL": 10,
+                    "ResourceRecords": resourcerecords,
+                },
+            }
+        )
+    create_1000_resource_records_payload = {
+        "Comment": "Create four records with 250 resource records each",
+        "Changes": changes,
+    }
+
+    conn.change_resource_record_sets(
+        HostedZoneId=hosted_zone_id, ChangeBatch=create_1000_resource_records_payload
+    )
+
+    # Changes creating over 1,000 resource records.
+    too_many_changes = create_1000_resource_records_payload["Changes"].copy()
+    too_many_changes.append(
+        {
+            "Action": "CREATE",
+            "ResourceRecordSet": {
+                "Name": "toomany.db.",
+                "Type": "A",
+                "TTL": 10,
+                "ResourceRecords": [{"Value": "127.0.0.1"}],
+            },
+        }
+    )
+
+    create_1001_resource_records_payload = {
+        "Comment": "Create four records with 250 resource records each, plus one more",
+        "Changes": too_many_changes,
+    }
+
+    with pytest.raises(ClientError) as exc:
+        conn.change_resource_record_sets(
+            HostedZoneId=hosted_zone_id,
+            ChangeBatch=create_1001_resource_records_payload,
+        )
+    err = exc.value.response["Error"]
+    err["Code"].should.equal("InvalidChangeBatch")
+
+    # Changes upserting exactly 500 resource records.
+    changes = []
+    for ci in range(2):
+        resourcerecords = []
+        for rri in range(250):
+            resourcerecords.append({"Value": "127.0.0.%d" % (rri)})
+        changes.append(
+            {
+                "Action": "UPSERT",
+                "ResourceRecordSet": {
+                    "Name": "foo%d.db." % (ci),
+                    "Type": "A",
+                    "TTL": 10,
+                    "ResourceRecords": resourcerecords,
+                },
+            }
+        )
+    upsert_500_resource_records_payload = {
+        "Comment": "Upsert two records with 250 resource records each",
+        "Changes": changes,
+    }
+
+    conn.change_resource_record_sets(
+        HostedZoneId=hosted_zone_id, ChangeBatch=upsert_500_resource_records_payload
+    )
+
+    # Changes upserting over 1,000 resource records.
+    too_many_changes = upsert_500_resource_records_payload["Changes"].copy()
+    too_many_changes.append(
+        {
+            "Action": "UPSERT",
+            "ResourceRecordSet": {
+                "Name": "toomany.db.",
+                "Type": "A",
+                "TTL": 10,
+                "ResourceRecords": [{"Value": "127.0.0.1"}],
+            },
+        }
+    )
+
+    upsert_501_resource_records_payload = {
+        "Comment": "Upsert two records with 250 resource records each, plus one more",
+        "Changes": too_many_changes,
+    }
+
+    with pytest.raises(ClientError) as exc:
+        conn.change_resource_record_sets(
+            HostedZoneId=hosted_zone_id, ChangeBatch=upsert_501_resource_records_payload
+        )
+    err = exc.value.response["Error"]
+    err["Code"].should.equal("InvalidChangeBatch")
+    err["Message"].should.equal("Number of records limit of 1000 exceeded.")