<!-- Love localstack? Please consider supporting our collective:
👉  https://opencollective.com/localstack/donate -->

Found a pagination bug with localstack's s3. Primarily against s3Client.listNextBatchOfObjects.

    private void test() {
        s3Client.putObject(s3BucketName, "key1", "content");
        s3Client.putObject(s3BucketName, "key2", "content");
        s3Client.putObject(s3BucketName, "key3", "content");

        ListObjectsRequest listObjectsRequest = new ListObjectsRequest()
                .withBucketName(s3BucketName)
                .withPrefix("")
                .withDelimiter("/")
                .setMaxKeys(1); // 1 Key per request

        ObjectListing objectListing = s3Client.listObjects(listObjectsRequest);
        List<SomeObject> someObjList = createEmptyList();
        someObjList.addAll(mapFilesToSomeObject(objectListing)); // puts at least 1 item into the list

        while (objectListing.isTruncated()) {
            objectListing = s3Client.listNextBatchOfObjects(objectListing);
            someObjList.addAll(mapFilesToSomeObject(objectListing));
        }
        assertEquals(3, someObjList.size());
    }

    private List<SomeObject> mapFilesToSomeObject(ObjectListing objectListing) {
        return objectListing.getObjectSummaries()
            .stream()
            .map(S3ObjectSummary::getKey)
            .map(x -> this.toSomeObject(x)) // some deserialization method
            .collect(Collectors.toList());
    }

This while loop will only get the second item in its first loop. It still enters the loop once more to grab the third item since the condition is still true, but the 2nd call of listNextBatchOfObjects in that very loop returns an empty list, thus failing to grab that third item. I confirmed that listNextBatchOfObjects retrieves the "second page" only against localstack, with changing the amount of items and page sizes. On AWS s3, this works as expected.

Refer to s3Client.listNextBatchOfObjects:
https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/services/s3/AmazonS3Client.html#listNextBatchOfObjects-com.amazonaws.services.s3.model.ObjectListing-
result = {'Error': {'Code': code, 'Message': message}}
    content = xmltodict.unparse(result)
    headers = {'content-type': 'application/xml'}
    return requests_response(content, status_code=status_code, headers=headers)
def expand_redirect_url(starting_url, key, bucket):
    """ Add key and bucket parameters to starting URL query string. """
    parsed = urlparse.urlparse(starting_url)
    query = collections.OrderedDict(urlparse.parse_qsl(parsed.query))
    query.update([('key', key), ('bucket', bucket)])
    redirect_url = urlparse.urlunparse((
        parsed.scheme, parsed.netloc, parsed.path,
        parsed.params, urlparse.urlencode(query), None))
    return redirect_url
def is_bucket_specified_in_domain_name(path, headers):
    host = headers.get('host', '')
    return re.match(r'.*s3(\-website)?\.([^\.]+\.)?amazonaws.com', host)
def is_object_specific_request(path, headers):
    """ Return whether the given request is specific to a certain S3 object.
        Note: the bucket name is usually specified as a path parameter,
        but may also be part of the domain name! """
    bucket_in_domain = is_bucket_specified_in_domain_name(path, headers)
    parts = len(path.split('/'))
    return parts > (1 if bucket_in_domain else 2)
def normalize_bucket_name(bucket_name):
    bucket_name = bucket_name or ''
    
    bucket_name = bucket_name.lower()
    return bucket_name
def get_key_name(path, headers):