<!-- 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-
if 'text/html' in response.headers.get('Content-Type', '') \
                        and not response_content_str.lower().startswith('<!doctype html'):
                    response.headers['Content-Type'] = 'application/xml; charset=utf-8'
                reset_content_length = True
            
            if method == 'DELETE':
                reset_content_length = True
            if reset_content_length:
                response.headers['content-length'] = len(response._content)
    def _update_location(self, content, bucket_name):
        bucket_name = normalize_bucket_name(bucket_name)
        host = config.HOSTNAME_EXTERNAL
        if ':' not in host:
            host = '%s:%s' % (host, config.PORT_S3)
        return re.sub(r'<Location>\s*([a-zA-Z0-9\-]+)://[^/]+/([^<]+)\s*</Location>',
            r'<Location>%s://%s/%s/\2</Location>' % (get_service_protocol(), host, bucket_name),
            content, flags=re.MULTILINE)
    @staticmethod
    def is_query_allowable(method, query):
        
        if not query:
            return True
        
        contains_cred = 'X-Amz-Credential' in query and 'X-Amz-Signature' in query
        contains_key = 'AWSAccessKeyId' in query and 'Signature' in query
        if (method == 'POST' and query.startswith('uploadId')) or contains_cred or contains_key:
            return True
UPDATE_S3 = ProxyListenerS3()