Repository: pypi/warehouse

Issue:
Fail gracefully if Redis isn't Up
Right now if the redis for the statistics go down, we'll start throwing errors when rendering the page, which is obviously less than desirable. Ideally we'll want to fail gracefully for this so that if redis goes down we'll just stop rendering statistics until it comes back. A tricky thing here is that we'll want to use ESI so that we don't cache for a long time the statistics with the failure output instead of statistics. This makes sense anyways since we'll want our stats to be updated quicker than the bulk of the page anyways. Additionally, we should ensure that we log the error regardless of whether we raise an exception or not.



Discussion:

Resolving patch:
diff --git a/warehouse/packaging/views.py b/warehouse/packaging/views.py
--- a/warehouse/packaging/views.py
+++ b/warehouse/packaging/views.py
@@ -91,19 +91,38 @@ def release_detail(release, request):
         )
     ]
 
-    stats_svc = request.find_service(IDownloadStatService)
-
     return {
         "project": project,
         "release": release,
         "files": release.files.all(),
         "all_releases": all_releases,
         "maintainers": maintainers,
-        "download_stats": {
-            "daily": stats_svc.get_daily_stats(project.name),
-            "weekly": stats_svc.get_weekly_stats(project.name),
-            "monthly": stats_svc.get_monthly_stats(project.name),
-        },
+    }
+
+
+@view_config(
+    route_name="esi.project-stats",
+    renderer="packaging/includes/project-stats.html",
+    decorator=[
+        origin_cache(
+            15 * 60,                         # 15 Minutes
+            stale_while_revalidate=30 * 60,  # 30 minutes
+            stale_if_error=30 * 60,          # 30 minutes
+        ),
+    ],
+)
+def project_stats(project, request):
+    if project.name != request.matchdict.get("name", project.name):
+        return HTTPMovedPermanently(
+            request.current_route_path(name=project.name),
+        )
+
+    stats_svc = request.find_service(IDownloadStatService)
+
+    return {
+        "daily": stats_svc.get_daily_stats(project.name),
+        "weekly": stats_svc.get_weekly_stats(project.name),
+        "monthly": stats_svc.get_monthly_stats(project.name),
     }
 
 
diff --git a/warehouse/routes.py b/warehouse/routes.py
--- a/warehouse/routes.py
+++ b/warehouse/routes.py
@@ -28,6 +28,13 @@ def includeme(config):
         "/_esi/current-user-indicator/",
         read_only=True,
     )
+    config.add_route(
+        "esi.project-stats",
+        "/_esi/project-stats/{name}/",
+        factory="warehouse.packaging.models:ProjectFactory",
+        traverse="/{name}",
+        read_only=True,
+    )
 
     # Accounts
     config.add_route(


Test patch:
diff --git a/tests/unit/packaging/test_views.py b/tests/unit/packaging/test_views.py
--- a/tests/unit/packaging/test_views.py
+++ b/tests/unit/packaging/test_views.py
@@ -18,7 +18,7 @@
 from webob import datetime_utils
 
 from warehouse.packaging import views
-from warehouse.packaging.interfaces import IFileStorage
+from warehouse.packaging.interfaces import IDownloadStatService, IFileStorage
 
 from ...common.db.accounts import UserFactory
 from ...common.db.packaging import (
@@ -125,16 +125,6 @@ def test_detail_renders(self, db_request):
             role_name="another role",
         )
 
-        daily_stats = pretend.stub()
-        weekly_stats = pretend.stub()
-        monthly_stats = pretend.stub()
-
-        db_request.find_service = lambda x: pretend.stub(
-            get_daily_stats=lambda p: daily_stats,
-            get_weekly_stats=lambda p: weekly_stats,
-            get_monthly_stats=lambda p: monthly_stats,
-        )
-
         result = views.release_detail(releases[1], db_request)
 
         assert result == {
@@ -145,14 +135,53 @@ def test_detail_renders(self, db_request):
                 (r.version, r.created) for r in reversed(releases)
             ],
             "maintainers": sorted(users, key=lambda u: u.username.lower()),
-            "download_stats": {
-                "daily": daily_stats,
-                "weekly": weekly_stats,
-                "monthly": monthly_stats,
-            },
         }
 
 
+class TestProjectStats:
+
+    def test_normalizing_redirects(self, pyramid_request):
+        project = pretend.stub(name="Foo")
+        name = project.name.lower()
+
+        pyramid_request.matchdict = {"name": name}
+        pyramid_request.current_route_path = pretend.call_recorder(
+            lambda name: "/_esi/project-stats/the-redirect/"
+        )
+
+        resp = views.project_stats(project, pyramid_request)
+
+        assert isinstance(resp, HTTPMovedPermanently)
+        assert resp.headers["Location"] == "/_esi/project-stats/the-redirect/"
+        assert pyramid_request.current_route_path.calls == [
+            pretend.call(name=project.name),
+        ]
+
+    def test_project_stats(self, pyramid_request):
+        project = pretend.stub(name="Foo")
+
+        class DownloadService:
+            _stats = {"Foo": {"daily": 10, "weekly": 70, "monthly": 300}}
+
+            def get_daily_stats(self, name):
+                return self._stats[name]["daily"]
+
+            def get_weekly_stats(self, name):
+                return self._stats[name]["weekly"]
+
+            def get_monthly_stats(self, name):
+                return self._stats[name]["monthly"]
+
+        services = {IDownloadStatService: DownloadService()}
+
+        pyramid_request.matchdict = {"name": project.name}
+        pyramid_request.find_service = lambda iface: services[iface]
+
+        stats = views.project_stats(project, pyramid_request)
+
+        assert stats == {"daily": 10, "weekly": 70, "monthly": 300}
+
+
 class TestPackages:
 
     def test_404_when_no_file(self, db_request):
diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py
--- a/tests/unit/test_routes.py
+++ b/tests/unit/test_routes.py
@@ -60,6 +60,13 @@ def add_xmlrpc_endpoint(endpoint, pattern, header, read_only=False):
             "/_esi/current-user-indicator/",
             read_only=True,
         ),
+        pretend.call(
+            "esi.project-stats",
+            "/_esi/project-stats/{name}/",
+            factory="warehouse.packaging.models:ProjectFactory",
+            traverse="/{name}",
+            read_only=True,
+        ),
         pretend.call(
             "accounts.profile",
             "/user/{username}/",