Repository: localstack/localstack

Issue:
bug: SQS attribute ApproximateNumberOfMessagesDelayed not updated in latest(0.14.4)
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

When I m defining a queue with 30s delay (DelaySeconds)
After sending message ApproximateNumberOfMessagesDelayed is still at 0

### Expected Behavior

Define a queue with 30s delay 
Send a message 
Verify value of ApproximateNumberOfMessagesDelayed in the queue, expected value should be 1.
This is working with release 0.14.2

### How are you starting LocalStack?

With a docker-compose file

### Steps To Reproduce

#### How are you starting localstack (e.g., `bin/localstack` command, arguments, or `docker-compose.yml`)

    docker run localstack/localstack:latest

#### Client commands (e.g., AWS SDK code snippet, or sequence of "awslocal" commands)
`
awslocal sqs create-queue  --queue-name test --attributes '{"DelaySeconds":"30","MessageRetentionPeriod":"86400","ReceiveMessageWaitTimeSeconds":"0","VisibilityTimeout":"0"}'

 awslocal sqs get-queue-attributes --attribute-names All  --queue-url http://localhost:4566/000000000000/test
 awslocal sqs send-message --queue-url http://localhost:4566/000000000000/test --message-body 'test'
 awslocal sqs get-queue-attributes --attribute-names All  --queue-url http://localhost:4566/000000000000/test
`

### Environment

```markdown
- OS:Ubuntu
- LocalStack:latest or 0.14.4
```


### Anything else?

_No response_


Discussion:
Welcome to LocalStack! Thanks for reporting your first issue and our team will be working towards fixing the issue for you or reach out for more background information. We recommend joining our [Slack Community](https://localstack.cloud/contact/) for real-time help and drop a message to LocalStack Pro Support if you are a Pro user! If you are willing to contribute towards fixing this issue, please have a look at our [contributing guidelines](https://github.com/localstack/.github/blob/main/CONTRIBUTING.md) and our [developer guide](https://docs.localstack.cloud/developer-guide/).
Hi @raphaelcolomine-onespan, thanks for reaching out. Looking at the code I can see that we have not yet implemented `ApproximateNumberOfMessagesDelayed` in our current SQS provider. Until we have fixed this, you can use as a workaround the old provider by starting localstack with `PROVIDER_OVERRIDE_SQS=legacy`.

The issue is with this code here: https://github.com/localstack/localstack/blob/8c7449864f8b9e74a709edbc78511e19246cf718/localstack/services/sqs/provider.py#L1066-L1074

Resolving patch:
diff --git a/localstack/services/sqs/provider.py b/localstack/services/sqs/provider.py
--- a/localstack/services/sqs/provider.py
+++ b/localstack/services/sqs/provider.py
@@ -70,6 +70,7 @@
 from localstack.utils.aws.aws_stack import parse_arn
 from localstack.utils.common import long_uid, md5, now, start_thread
 from localstack.utils.run import FuncThread
+from localstack.utils.scheduler import Scheduler
 
 LOG = logging.getLogger(__name__)
 
@@ -164,11 +165,14 @@ class Permission(NamedTuple):
 
 class SqsMessage:
     message: Message
+    created: float
     visibility_timeout: int
     receive_times: int
+    delay_seconds: Optional[int]
     receipt_handles: Set[str]
     last_received: Optional[float]
     first_received: Optional[float]
+    visibility_deadline: Optional[float]
     deleted: bool
     priority: float
     message_deduplication_id: str
@@ -181,10 +185,12 @@ def __init__(
         message_deduplication_id: str = None,
         message_group_id: str = None,
     ) -> None:
+        self.created = time.time()
         self.message = message
         self.receive_times = 0
         self.receipt_handles = set()
 
+        self.delay_seconds = None
         self.last_received = None
         self.first_received = None
         self.deleted = False
@@ -209,15 +215,45 @@ def message_group_id(self) -> Optional[str]:
     def message_deduplication_id(self) -> Optional[str]:
         return self.message["Attributes"].get("MessageDeduplicationId")
 
+    def set_last_received(self, timestamp: float):
+        """
+        Sets the last received timestamp of the message to the given value, and updates the visibility deadline
+        accordingly.
+
+        :param timestamp: the last time the message was received
+        """
+        self.last_received = timestamp
+        self.visibility_deadline = timestamp + self.visibility_timeout
+
+    def update_visibility_timeout(self, timeout: int):
+        """
+        Sets the visibility timeout of the message to the given value, and updates the visibility deadline accordingly.
+
+        :param timeout: the timeout value in seconds
+        """
+        self.visibility_timeout = timeout
+        self.visibility_deadline = time.time() + timeout
+
     @property
-    def is_visible(self):
-        if self.last_received is None:
+    def is_visible(self) -> bool:
+        """
+        Returns false if the message has a visibility deadline that is in the future.
+
+        :return: whether the message is visibile or not.
+        """
+        if self.visibility_deadline is None:
             return True
-        if time.time() >= (self.last_received + self.visibility_timeout):
+        if time.time() >= self.visibility_deadline:
             return True
 
         return False
 
+    @property
+    def is_delayed(self) -> bool:
+        if self.delay_seconds is None:
+            return False
+        return time.time() <= self.created + self.delay_seconds
+
     def __gt__(self, other):
         return self.priority > other.priority
 
@@ -249,6 +285,7 @@ class SqsQueue:
     purge_in_progress: bool
 
     visible: PriorityQueue
+    delayed: Set[SqsMessage]
     inflight: Set[SqsMessage]
     receipts: Dict[str, SqsMessage]
 
@@ -261,6 +298,7 @@ def __init__(self, name: str, region: str, account_id: str, attributes=None, tag
         self.tags = tags or {}
 
         self.visible = PriorityQueue()
+        self.delayed = set()
         self.inflight = set()
         self.receipts = {}
 
@@ -276,7 +314,7 @@ def default_attributes(self) -> QueueAttributeMap:
         return {
             QueueAttributeName.ApproximateNumberOfMessages: self.visible._qsize,
             QueueAttributeName.ApproximateNumberOfMessagesNotVisible: lambda: len(self.inflight),
-            QueueAttributeName.ApproximateNumberOfMessagesDelayed: "0",  # FIXME: this should also be callable
+            QueueAttributeName.ApproximateNumberOfMessagesDelayed: lambda: len(self.delayed),
             QueueAttributeName.CreatedTimestamp: str(now()),
             QueueAttributeName.DelaySeconds: "0",
             QueueAttributeName.LastModifiedTimestamp: str(now()),
@@ -288,6 +326,17 @@ def default_attributes(self) -> QueueAttributeMap:
             QueueAttributeName.SqsManagedSseEnabled: "false",
         }
 
+    def update_delay_seconds(self, value: int):
+        """
+        For standard queues, the per-queue delay setting is not retroactive—changing the setting doesn't affect the delay of messages already in the queue.
+        For FIFO queues, the per-queue delay setting is retroactive—changing the setting affects the delay of messages already in the queue.
+
+        https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-delay-queues.html
+
+        :param value: the number of seconds
+        """
+        self.attributes[QueueAttributeName.DelaySeconds] = str(value)
+
     def update_last_modified(self, timestamp: int = None):
         if timestamp is None:
             timestamp = now()
@@ -327,6 +376,10 @@ def url(self, context: RequestContext) -> str:
     def visibility_timeout(self) -> int:
         return int(self.attributes[QueueAttributeName.VisibilityTimeout])
 
+    @property
+    def delay_seconds(self) -> int:
+        return int(self.attributes[QueueAttributeName.DelaySeconds])
+
     @property
     def wait_time_seconds(self) -> int:
         return int(self.attributes[QueueAttributeName.ReceiveMessageWaitTimeSeconds])
@@ -352,7 +405,7 @@ def update_visibility_timeout(self, receipt_handle: str, visibility_timeout: int
             if standard_message not in self.inflight:
                 raise MessageNotInflight()
 
-            standard_message.visibility_timeout = visibility_timeout
+            standard_message.update_visibility_timeout(visibility_timeout)
 
             if visibility_timeout == 0:
                 LOG.info(
@@ -406,6 +459,7 @@ def put(
         visibility_timeout: int = None,
         message_deduplication_id: str = None,
         message_group_id: str = None,
+        delay_seconds: int = None,
     ):
         raise NotImplementedError
 
@@ -430,7 +484,7 @@ def get(self, block=True, timeout=None, visibility_timeout: int = None) -> SqsMe
                     self.visibility_timeout if visibility_timeout is None else visibility_timeout
                 )
                 standard_message.receive_times += 1
-                standard_message.last_received = time.time()
+                standard_message.set_last_received(time.time())
                 if standard_message.first_received is None:
                     standard_message.first_received = standard_message.last_received
 
@@ -464,16 +518,30 @@ def requeue_inflight_messages(self):
             return
 
         with self.mutex:
-            messages = list(self.inflight)
+            messages = [message for message in self.inflight if message.is_visible]
             for standard_message in messages:
-                if standard_message.is_visible:
-                    LOG.debug(
-                        "re-queueing inflight messages %s into queue %s",
-                        standard_message.message["MessageId"],
-                        self.arn,
-                    )
-                    self.inflight.remove(standard_message)
-                    self.visible.put_nowait(standard_message)
+                LOG.debug(
+                    "re-queueing inflight messages %s into queue %s",
+                    standard_message.message["MessageId"],
+                    self.arn,
+                )
+                self.inflight.remove(standard_message)
+                self.visible.put_nowait(standard_message)
+
+    def enqueue_delayed_messages(self):
+        if not self.delayed:
+            return
+
+        with self.mutex:
+            messages = [message for message in self.delayed if not message.is_delayed]
+            for standard_message in messages:
+                LOG.debug(
+                    "enqueueing delayed messages %s into queue %s",
+                    standard_message.message["MessageId"],
+                    self.arn,
+                )
+                self.delayed.remove(standard_message)
+                self.visible.put_nowait(standard_message)
 
     def _assert_queue_name(self, name):
         if not re.match(r"^[a-zA-Z0-9_-]{1,80}$", name):
@@ -500,8 +568,8 @@ def put(
         visibility_timeout: int = None,
         message_deduplication_id: str = None,
         message_group_id: str = None,
+        delay_seconds: int = None,
     ):
-
         if message_deduplication_id:
             raise InvalidParameterValue(
                 f"Value {message_deduplication_id} for parameter MessageDeduplicationId is invalid. Reason: The "
@@ -521,26 +589,44 @@ def put(
             # use the attribute from the queue
             standard_message.visibility_timeout = self.visibility_timeout
 
-        self.visible.put_nowait(standard_message)
+        if delay_seconds is not None:
+            standard_message.delay_seconds = delay_seconds
+        else:
+            standard_message.delay_seconds = self.delay_seconds
+
+        if standard_message.is_delayed:
+            self.delayed.add(standard_message)
+        else:
+            self.visible.put_nowait(standard_message)
 
 
 class FifoQueue(SqsQueue):
-    visible: PriorityQueue
-    inflight: Set[SqsMessage]
-    receipts: Dict[str, SqsMessage]
+
     deduplication: Dict[str, Dict[str, SqsMessage]]
 
     def __init__(self, name: str, region: str, account_id: str, attributes=None, tags=None) -> None:
         super().__init__(name, region, account_id, attributes, tags)
         self.deduplication = {}
 
+    def update_delay_seconds(self, value: int):
+        super(FifoQueue, self).update_delay_seconds(value)
+        for message in self.delayed:
+            message.delay_seconds = value
+
     def put(
         self,
         message: Message,
         visibility_timeout: int = None,
         message_deduplication_id: str = None,
         message_group_id: str = None,
+        delay_seconds: int = None,
     ):
+        if delay_seconds is not None:
+            # in fifo queues, delay is only applied on queue level
+            raise InvalidParameterValue(
+                f"Value {delay_seconds} for parameter DelaySeconds is invalid. Reason: The request include parameter "
+                f"that is not valid for this queue type."
+            )
 
         if not message_group_id:
             raise MissingParameter("The request must contain the parameter MessageGroupId.")
@@ -557,32 +643,43 @@ def put(
                 "explicitly "
             )
 
-        qm = SqsMessage(
+        fifo_message = SqsMessage(
             time.time(),
             message,
             message_deduplication_id=dedup_id,
             message_group_id=message_group_id,
         )
         if visibility_timeout is not None:
-            qm.visibility_timeout = visibility_timeout
+            fifo_message.visibility_timeout = visibility_timeout
         else:
             # use the attribute from the queue
-            qm.visibility_timeout = self.visibility_timeout
+            fifo_message.visibility_timeout = self.visibility_timeout
+
+        if delay_seconds is not None:
+            fifo_message.delay_seconds = delay_seconds
+        else:
+            fifo_message.delay_seconds = self.delay_seconds
+
         original_message = None
         original_message_group = self.deduplication.get(message_group_id)
         if original_message_group:
             original_message = original_message_group.get(dedup_id)
+
         if (
             original_message
             and not original_message.deleted
-            and original_message.priority + DEDUPLICATION_INTERVAL_IN_SEC > qm.priority
+            and original_message.priority + DEDUPLICATION_INTERVAL_IN_SEC > fifo_message.priority
         ):
             message["MessageId"] = original_message.message["MessageId"]
         else:
-            self.visible.put_nowait(qm)
+            if fifo_message.is_delayed:
+                self.delayed.add(fifo_message)
+            else:
+                self.visible.put_nowait(fifo_message)
+
             if not original_message_group:
                 self.deduplication[message_group_id] = {}
-            self.deduplication[message_group_id][dedup_id] = qm
+            self.deduplication[message_group_id][dedup_id] = fifo_message
 
     def _assert_queue_name(self, name):
         if not name.endswith(".fifo"):
@@ -613,42 +710,55 @@ def generate_sequence_number(self):
         return _create_mock_sequence_number()
 
 
-class InflightUpdateWorker:
+class QueueUpdateWorker:
     """
-    Regularly re-queues inflight messages whose visibility timeout has expired.
-
-    FIXME: very crude implementation. it would be better to have event-driven communication.
+    Regularly re-queues inflight and delayed messages whose visibility timeout has expired or delay deadline has been
+    reached.
     """
 
     def __init__(self) -> None:
         super().__init__()
-        self.running = False
+        self.scheduler = Scheduler()
         self.thread: Optional[FuncThread] = None
+        self.mutex = threading.RLock()
+
+    def do_update_all_queues(self):
+        for region in SqsBackend.regions().keys():
+            backend = SqsBackend.get(region)
+            for queue in backend.queues.values():
+                try:
+                    queue.requeue_inflight_messages()
+                except Exception:
+                    LOG.exception("error re-queueing inflight messages")
+
+                try:
+                    queue.enqueue_delayed_messages()
+                except Exception:
+                    LOG.exception("error enqueueing delayed messages")
 
     def start(self):
-        if self.thread:
-            return
+        with self.mutex:
+            if self.thread:
+                return
+
+            self.scheduler = Scheduler()
+            self.scheduler.schedule(self.do_update_all_queues, period=1)
 
-        def _run(*_args):
-            self.running = True
-            self.run()
+            def _run(*_args):
+                self.scheduler.run()
 
-        self.thread = start_thread(_run)
+            self.thread = start_thread(_run)
 
     def stop(self):
-        if self.thread:
-            self.thread.stop()
-
-        self.running = False
-        self.thread = None
-
-    def run(self):
-        while self.running:
-            time.sleep(1)
-            for region in SqsBackend.regions().keys():
-                backend = SqsBackend.get(region)
-                for queue in backend.queues.values():
-                    queue.requeue_inflight_messages()
+        with self.mutex:
+            if self.scheduler:
+                self.scheduler.close()
+
+            if self.thread:
+                self.thread.stop()
+
+            self.thread = None
+            self.scheduler = None
 
 
 def check_attributes(message_attributes: MessageBodyAttributeMap):
@@ -742,13 +852,13 @@ class SqsProvider(SqsApi, ServiceLifecycleHook):
     def __init__(self) -> None:
         super().__init__()
         self._mutex = threading.RLock()
-        self._inflight_worker = InflightUpdateWorker()
+        self._queue_update_worker = QueueUpdateWorker()
 
     def on_before_start(self):
-        self._inflight_worker.start()
+        self._queue_update_worker.start()
 
     def on_before_stop(self):
-        self._inflight_worker.stop()
+        self._queue_update_worker.stop()
 
     def _require_queue(self, context: RequestContext, name: str) -> SqsQueue:
         """
@@ -1054,7 +1164,7 @@ def _put_message(
         check_fifo_id(message_deduplication_id)
         check_fifo_id(message_group_id)
 
-        message: Message = Message(
+        message = Message(
             MessageId=generate_message_id(),
             MD5OfBody=md5(message_body),
             Body=message_body,
@@ -1062,23 +1172,13 @@ def _put_message(
             MD5OfMessageAttributes=_create_message_attribute_hash(message_attributes),
             MessageAttributes=message_attributes,
         )
-        delay_seconds = delay_seconds or queue.attributes.get(QueueAttributeName.DelaySeconds, "0")
-
-        if int(delay_seconds):
-            # FIXME: this is a pretty bad implementation (one thread per message...). polling on a priority queue
-            #  would probably be better. We also need access to delayed messages for the
-            #  ApproximateNumberrOfDelayedMessages attribute.
-            threading.Timer(
-                int(delay_seconds),
-                queue.put,
-                args=(message, message_deduplication_id, message_group_id),
-            ).start()
-        else:
-            queue.put(
-                message=message,
-                message_deduplication_id=message_deduplication_id,
-                message_group_id=message_group_id,
-            )
+
+        queue.put(
+            message=message,
+            message_deduplication_id=message_deduplication_id,
+            message_group_id=message_group_id,
+            delay_seconds=int(delay_seconds) if delay_seconds is not None else None,
+        )
 
         return message
 


Test patch:
diff --git a/tests/integration/test_sqs.py b/tests/integration/test_sqs.py
--- a/tests/integration/test_sqs.py
+++ b/tests/integration/test_sqs.py
@@ -496,6 +496,45 @@ def test_send_delay_and_wait_time(self, sqs_client, sqs_queue):
         assert "Messages" in result
         assert len(result["Messages"]) == 1
 
+    @pytest.mark.only_localstack
+    def test_approximate_number_of_messages_delayed(self, sqs_client, sqs_queue):
+        # this test does not work against AWS in the same way, because AWS only has eventual consistency guarantees
+        # for the tested attributes that can take up to a minute to update.
+        sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="ed")
+        sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="foo", DelaySeconds=2)
+        sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="bar", DelaySeconds=2)
+
+        result = sqs_client.get_queue_attributes(
+            QueueUrl=sqs_queue,
+            AttributeNames=[
+                "ApproximateNumberOfMessages",
+                "ApproximateNumberOfMessagesNotVisible",
+                "ApproximateNumberOfMessagesDelayed",
+            ],
+        )
+        assert result["Attributes"] == {
+            "ApproximateNumberOfMessages": "1",
+            "ApproximateNumberOfMessagesNotVisible": "0",
+            "ApproximateNumberOfMessagesDelayed": "2",
+        }
+
+        def _assert():
+            _result = sqs_client.get_queue_attributes(
+                QueueUrl=sqs_queue,
+                AttributeNames=[
+                    "ApproximateNumberOfMessages",
+                    "ApproximateNumberOfMessagesNotVisible",
+                    "ApproximateNumberOfMessagesDelayed",
+                ],
+            )
+            assert _result["Attributes"] == {
+                "ApproximateNumberOfMessages": "3",
+                "ApproximateNumberOfMessagesNotVisible": "0",
+                "ApproximateNumberOfMessagesDelayed": "0",
+            }
+
+        retry(_assert)
+
     @pytest.mark.aws_validated
     def test_receive_after_visibility_timeout(self, sqs_client, sqs_create_queue):
         queue_url = sqs_create_queue(Attributes={"VisibilityTimeout": "1"})
@@ -542,8 +581,66 @@ def test_receive_terminate_visibility_timeout(self, sqs_client, sqs_queue):
         result = sqs_client.receive_message(QueueUrl=queue_url)
         assert "Messages" not in result
 
-    def test_update_message_visibility_timeout(self):
-        pass
+    @pytest.mark.aws_validated
+    def test_extend_message_visibility_timeout_set_in_queue(self, sqs_client, sqs_create_queue):
+        queue_url = sqs_create_queue(Attributes={"VisibilityTimeout": "2"})
+
+        sqs_client.send_message(QueueUrl=queue_url, MessageBody="test")
+        response = sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5)
+        receipt = response["Messages"][0]["ReceiptHandle"]
+
+        # update even if time expires
+        for _ in range(4):
+            time.sleep(1)
+            # we've waited a total of four seconds, although the visibility timeout is 2, so we are extending it
+            sqs_client.change_message_visibility(
+                QueueUrl=queue_url, ReceiptHandle=receipt, VisibilityTimeout=2
+            )
+            assert sqs_client.receive_message(QueueUrl=queue_url).get("Messages", []) == []
+
+        messages = sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5)["Messages"]
+        assert messages[0]["Body"] == "test"
+        assert len(messages) == 1
+
+    @pytest.mark.aws_validated
+    def test_receive_message_with_visibility_timeout_updates_timeout(
+        self, sqs_client, sqs_create_queue
+    ):
+        queue_url = sqs_create_queue()
+
+        sqs_client.send_message(QueueUrl=queue_url, MessageBody="test")
+
+        response = sqs_client.receive_message(
+            QueueUrl=queue_url, WaitTimeSeconds=2, VisibilityTimeout=0
+        )
+        assert len(response["Messages"]) == 1
+
+        response = sqs_client.receive_message(QueueUrl=queue_url, VisibilityTimeout=3)
+        assert len(response["Messages"]) == 1
+
+        response = sqs_client.receive_message(QueueUrl=queue_url)
+        assert response.get("Messages", []) == []
+
+    @pytest.mark.aws_validated
+    def test_terminate_visibility_timeout_after_receive(self, sqs_client, sqs_create_queue):
+        queue_url = sqs_create_queue()
+
+        sqs_client.send_message(QueueUrl=queue_url, MessageBody="test")
+
+        response = sqs_client.receive_message(
+            QueueUrl=queue_url, WaitTimeSeconds=2, VisibilityTimeout=0
+        )
+        receipt_1 = response["Messages"][0]["ReceiptHandle"]
+        assert len(response["Messages"]) == 1
+
+        response = sqs_client.receive_message(QueueUrl=queue_url, VisibilityTimeout=3)
+        assert len(response["Messages"]) == 1
+
+        sqs_client.change_message_visibility(
+            QueueUrl=queue_url, ReceiptHandle=receipt_1, VisibilityTimeout=0
+        )
+        response = sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=1)
+        assert len(response["Messages"]) == 1
 
     def test_delete_message_batch_from_lambda(
         self, sqs_client, sqs_create_queue, lambda_client, create_lambda_function
@@ -753,6 +850,52 @@ def receive_and_check_order():
         time.sleep(timeout + 1)
         receive_and_check_order()
 
+    @pytest.mark.aws_validated
+    def test_fifo_queue_send_message_with_delay_seconds_fails(
+        self, sqs_client, sqs_create_queue, snapshot
+    ):
+        queue_url = sqs_create_queue(
+            QueueName=f"queue-{short_uid()}.fifo",
+            Attributes={"FifoQueue": "true", "ContentBasedDeduplication": "true"},
+        )
+
+        with pytest.raises(ClientError) as e:
+            sqs_client.send_message(
+                QueueUrl=queue_url, MessageBody="message-1", MessageGroupId="1", DelaySeconds=2
+            )
+
+        snapshot.match("send_message", e.value)
+
+    @pytest.mark.aws_validated
+    def test_fifo_queue_send_message_with_delay_on_queue_works(self, sqs_client, sqs_create_queue):
+        queue_url = sqs_create_queue(
+            QueueName=f"queue-{short_uid()}.fifo",
+            Attributes={
+                "FifoQueue": "true",
+                "ContentBasedDeduplication": "true",
+                "DelaySeconds": "2",
+            },
+        )
+
+        sqs_client.send_message(QueueUrl=queue_url, MessageBody="message-1", MessageGroupId="1")
+        sqs_client.send_message(QueueUrl=queue_url, MessageBody="message-2", MessageGroupId="2")
+        sqs_client.send_message(QueueUrl=queue_url, MessageBody="message-3", MessageGroupId="3")
+
+        response = sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=1)
+        assert response.get("Messages", []) == []
+
+        messages = []
+
+        def _collect():
+            _response = sqs_client.receive_message(QueueUrl=queue_url)
+            messages.extend(_response.get("Messages", []))
+            assert len(messages) == 3
+
+        retry(_collect, sleep_before=2)  # let the delay expire first
+        assert messages[0]["Body"] == "message-1"
+        assert messages[1]["Body"] == "message-2"
+        assert messages[2]["Body"] == "message-3"
+
     @pytest.mark.aws_validated
     def test_list_queue_tags(self, sqs_client, sqs_create_queue):
         queue_name = f"queue-{short_uid()}"
@@ -1634,10 +1777,6 @@ def test_posting_to_fifo_requires_deduplicationid_group_id(self, sqs_client, sqs
             )
         e.match("MissingParameter")
 
-    def test_approximate_number_of_messages_delayed(self):
-        # TODO: test approximateNumberOfMessages once delayed Messages are properly counted
-        pass
-
     @pytest.mark.aws_validated
     def test_posting_to_queue_via_queue_name(self, sqs_client, sqs_create_queue):
         # TODO: behaviour diverges from AWS
diff --git a/tests/integration/test_sqs.snapshot.json b/tests/integration/test_sqs.snapshot.json
--- a/tests/integration/test_sqs.snapshot.json
+++ b/tests/integration/test_sqs.snapshot.json
@@ -344,5 +344,11 @@
     "recorded-content": {
       "error": "An error occurred (InvalidAttributeName) when calling the SetQueueAttributes operation: You can use one type of server-side encryption (SSE) at one time. You can either enable KMS SSE or SQS SSE."
     }
+  },
+  "tests/integration/test_sqs.py::TestSqsProvider::test_fifo_queue_send_message_with_delay_seconds_fails": {
+    "recorded-date": "03-08-2022, 18:36:23",
+    "recorded-content": {
+      "send_message": "An error occurred (InvalidParameterValue) when calling the SendMessage operation: Value 2 for parameter DelaySeconds is invalid. Reason: The request include parameter that is not valid for this queue type."
+    }
   }
 }