Repository: getmoto/moto

Issue:
SNS unsubscribing a a subscription that doesn't exist causes KeyError
Calling SNS unsubscribe and passing a SubscriptionArn that doesn't exist causes a somewhat confusing KeyError to be raised by boto:

```
handler.py:163: in register_device_for_sns
    notification_service.remove_subscription(subscription_arn)
sns_service.py:143: in remove_subscription
    SubscriptionArn=subscription_arn
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/client.py:272: in _api_call
    return self._make_api_call(operation_name, kwargs)
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/client.py:563: in _make_api_call
    operation_model, request_dict, request_context)
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/client.py:582: in _make_request
    return self._endpoint.make_request(operation_model, request_dict)
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/endpoint.py:102: in make_request
    return self._send_request(request_dict, operation_model)
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/endpoint.py:137: in _send_request
    success_response, exception):
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/endpoint.py:231: in _needs_retry
    caught_exception=caught_exception, request_dict=request_dict)
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/hooks.py:356: in emit
    return self._emitter.emit(aliased_event_name, **kwargs)
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/hooks.py:228: in emit
    return self._emit(event_name, kwargs)
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/hooks.py:211: in _emit
    response = handler(**kwargs)
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/retryhandler.py:183: in __call__
    if self._checker(attempts, response, caught_exception):
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/retryhandler.py:251: in __call__
    caught_exception)
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/retryhandler.py:269: in _should_retry
    return self._checker(attempt_number, response, caught_exception)
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/retryhandler.py:317: in __call__
    caught_exception)
../../../.tox_virtualenvs/lambda_sns/py27/lib/python2.7/site-packages/botocore/retryhandler.py:223: in __call__
    attempt_number, caught_exception)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <botocore.retryhandler.ExceptionRaiser object at 0x112b9a2d0>, attempt_number = 1, caught_exception = KeyError(u'foobar',)

    def _check_caught_exception(self, attempt_number, caught_exception):
        # This is implementation specific, but this class is useful by
        # coordinating with the MaxAttemptsDecorator.
        # The MaxAttemptsDecorator has a list of exceptions it should catch
        # and retry, but something needs to come along and actually raise the
        # caught_exception.  That's what this class is being used for.  If
        # the MaxAttemptsDecorator is not interested in retrying the exception
        # then this exception just propogates out past the retry code.
>       raise caught_exception
E       KeyError: u'foobar'
```

boto allows you do to this and the API returns a 200 response.

changing the following line to: `self.subscriptions.pop(subscription_arn, None)` solves the issue for me. https://github.com/spulec/moto/blob/master/moto/sns/models.py#L469



Discussion:

Resolving patch:
diff --git a/moto/sns/models.py b/moto/sns/models.py
--- a/moto/sns/models.py
+++ b/moto/sns/models.py
@@ -397,10 +397,6 @@ def list_topics(self, next_token=None):
         return self._get_values_nexttoken(self.topics, next_token)
 
     def delete_topic(self, arn):
-        topic = self.get_topic(arn)
-        subscriptions = self._get_topic_subscriptions(topic)
-        for sub in subscriptions:
-            self.unsubscribe(sub.arn)
         self.topics.pop(arn)
 
     def get_topic(self, arn):
@@ -466,7 +462,7 @@ def _find_subscription(self, topic_arn, endpoint, protocol):
         return None
 
     def unsubscribe(self, subscription_arn):
-        self.subscriptions.pop(subscription_arn)
+        self.subscriptions.pop(subscription_arn, None)
 
     def list_subscriptions(self, topic_arn=None, next_token=None):
         if topic_arn:


Test patch:
diff --git a/tests/test_sns/test_subscriptions.py b/tests/test_sns/test_subscriptions.py
--- a/tests/test_sns/test_subscriptions.py
+++ b/tests/test_sns/test_subscriptions.py
@@ -54,9 +54,10 @@ def test_deleting_subscriptions_by_deleting_topic():
     ]["Subscriptions"]
     subscriptions.should.have.length_of(1)
     subscription = subscriptions[0]
+    subscription_arn = subscription["SubscriptionArn"]
     subscription["TopicArn"].should.equal(topic_arn)
     subscription["Protocol"].should.equal("http")
-    subscription["SubscriptionArn"].should.contain(topic_arn)
+    subscription_arn.should.contain(topic_arn)
     subscription["Endpoint"].should.equal("http://example.com/")
 
     # Now delete the topic
@@ -67,12 +68,25 @@ def test_deleting_subscriptions_by_deleting_topic():
     topics = topics_json["ListTopicsResponse"]["ListTopicsResult"]["Topics"]
     topics.should.have.length_of(0)
 
-    # And there should be zero subscriptions left
+    # And the subscription should still be left
+    subscriptions = conn.get_all_subscriptions()["ListSubscriptionsResponse"][
+        "ListSubscriptionsResult"
+    ]["Subscriptions"]
+    subscriptions.should.have.length_of(1)
+    subscription = subscriptions[0]
+    subscription["SubscriptionArn"].should.equal(subscription_arn)
+
+    # Now delete hanging subscription
+    conn.unsubscribe(subscription_arn)
+
     subscriptions = conn.get_all_subscriptions()["ListSubscriptionsResponse"][
         "ListSubscriptionsResult"
     ]["Subscriptions"]
     subscriptions.should.have.length_of(0)
 
+    # Deleting it again should not result in any error
+    conn.unsubscribe(subscription_arn)
+
 
 @mock_sns_deprecated
 def test_getting_subscriptions_by_topic():
diff --git a/tests/test_sns/test_subscriptions_boto3.py b/tests/test_sns/test_subscriptions_boto3.py
--- a/tests/test_sns/test_subscriptions_boto3.py
+++ b/tests/test_sns/test_subscriptions_boto3.py
@@ -97,34 +97,48 @@ def test_creating_subscription():
 
 
 @mock_sns
-def test_deleting_subscriptions_by_deleting_topic():
-    conn = boto3.client("sns", region_name="us-east-1")
-    conn.create_topic(Name="some-topic")
-    response = conn.list_topics()
+def test_unsubscribe_from_deleted_topic():
+    client = boto3.client("sns", region_name="us-east-1")
+    client.create_topic(Name="some-topic")
+    response = client.list_topics()
     topic_arn = response["Topics"][0]["TopicArn"]
 
-    conn.subscribe(TopicArn=topic_arn, Protocol="http", Endpoint="http://example.com/")
+    client.subscribe(
+        TopicArn=topic_arn, Protocol="http", Endpoint="http://example.com/"
+    )
 
-    subscriptions = conn.list_subscriptions()["Subscriptions"]
+    subscriptions = client.list_subscriptions()["Subscriptions"]
     subscriptions.should.have.length_of(1)
     subscription = subscriptions[0]
+    subscription_arn = subscription["SubscriptionArn"]
     subscription["TopicArn"].should.equal(topic_arn)
     subscription["Protocol"].should.equal("http")
-    subscription["SubscriptionArn"].should.contain(topic_arn)
+    subscription_arn.should.contain(topic_arn)
     subscription["Endpoint"].should.equal("http://example.com/")
 
     # Now delete the topic
-    conn.delete_topic(TopicArn=topic_arn)
+    client.delete_topic(TopicArn=topic_arn)
 
     # And there should now be 0 topics
-    topics_json = conn.list_topics()
+    topics_json = client.list_topics()
     topics = topics_json["Topics"]
     topics.should.have.length_of(0)
 
-    # And there should be zero subscriptions left
-    subscriptions = conn.list_subscriptions()["Subscriptions"]
+    # And the subscription should still be left
+    subscriptions = client.list_subscriptions()["Subscriptions"]
+    subscriptions.should.have.length_of(1)
+    subscription = subscriptions[0]
+    subscription["SubscriptionArn"].should.equal(subscription_arn)
+
+    # Now delete hanging subscription
+    client.unsubscribe(SubscriptionArn=subscription_arn)
+
+    subscriptions = client.list_subscriptions()["Subscriptions"]
     subscriptions.should.have.length_of(0)
 
+    # Deleting it again should not result in any error
+    client.unsubscribe(SubscriptionArn=subscription_arn)
+
 
 @mock_sns
 def test_getting_subscriptions_by_topic():