Repos / pytaku / 3750862e6f
commit 3750862e6f50e8f8a112326063e1db3b337410c6
Author: Bùi Thành Nhân <hi@imnhan.com>
Date:   Thu Aug 27 21:39:21 2020 +0700

    show source site error to user
    
    New `@handle_source_site_errors` decorator that supports either html or
    json responses.
    
    - If html: just render a plain page
    - If json: count on frontend code to report error appropriately.
      Currently it just `alert()`s the thing.
      Need to figure out a better UX.

diff --git a/pyproject.toml b/pyproject.toml
index e6764bf..c95133f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "pytaku"
-version = "0.3.11"
+version = "0.3.12"
 description = "Self-hostable web-based manga reader"
 authors = ["Bùi Thành Nhân <hi@imnhan.com>"]
 license = "AGPL-3.0-only"
diff --git a/src/mangoapi/base_site.py b/src/mangoapi/base_site.py
index 6f1884d..9bb4dde 100644
--- a/src/mangoapi/base_site.py
+++ b/src/mangoapi/base_site.py
@@ -3,7 +3,11 @@
 
 import requests
 
-from .exceptions import SourceSite5xxError, SourceSiteUnexpectedError
+from .exceptions import (
+    SourceSite5xxError,
+    SourceSiteTimeoutError,
+    SourceSiteUnexpectedError,
+)
 
 
 class Site(ABC):
@@ -46,18 +50,21 @@ def title_source_url(self, title_id):
     def login(self, username, password):
         raise NotImplementedError()
 
-    def _http_request(self, method, *args, **kwargs):
+    def _http_request(self, method, url, *args, **kwargs):
         request_func = getattr(self._session, method)
 
         if "timeout" not in kwargs:
             kwargs["timeout"] = 5
 
-        resp = request_func(*args, **kwargs)
+        try:
+            resp = request_func(url, *args, **kwargs)
+        except requests.exceptions.Timeout:
+            raise SourceSiteTimeoutError(url)
 
         if 500 <= resp.status_code <= 599:
-            raise SourceSite5xxError(resp.text)
+            raise SourceSite5xxError(url, resp.status_code, resp.text)
         elif resp.status_code != 200:
-            raise SourceSiteUnexpectedError(resp.status_code, resp.text)
+            raise SourceSiteUnexpectedError(url, resp.status_code, resp.text)
 
         return resp
 
diff --git a/src/mangoapi/exceptions.py b/src/mangoapi/exceptions.py
index a48d331..728d4ab 100644
--- a/src/mangoapi/exceptions.py
+++ b/src/mangoapi/exceptions.py
@@ -1,8 +1,17 @@
-class SourceSite5xxError(Exception):
+class SourceSiteResponseError(Exception):
+    def __init__(self, url, status_code=None, response_text=None):
+        self.url = url
+        self.status_code = status_code
+        self.response_text = response_text
+
+
+class SourceSiteTimeoutError(SourceSiteResponseError):
     pass
 
 
-class SourceSiteUnexpectedError(Exception):
-    def __init__(self, status_code, resp_text):
-        self.status_code = status_code
-        self.resp_text = resp_text
+class SourceSite5xxError(SourceSiteResponseError):
+    pass
+
+
+class SourceSiteUnexpectedError(SourceSiteResponseError):
+    pass
diff --git a/src/pytaku/decorators.py b/src/pytaku/decorators.py
index 01d1a8d..c7a86c7 100644
--- a/src/pytaku/decorators.py
+++ b/src/pytaku/decorators.py
@@ -1,6 +1,13 @@
 from functools import wraps
 
-from flask import jsonify, request
+from flask import jsonify, render_template, request
+
+from mangoapi.exceptions import (
+    SourceSite5xxError,
+    SourceSiteResponseError,
+    SourceSiteTimeoutError,
+    SourceSiteUnexpectedError,
+)
 
 from .persistence import verify_token
 
@@ -33,3 +40,40 @@ def decorated_function(*args, **kwargs):
         return decorated_function
 
     return decorator
+
+
+SOURCE_SITE_ERRORS = {
+    SourceSiteTimeoutError: {
+        "code": "source_site_timeout",
+        "message": "Source site took too long to respond. Try again later.",
+    },
+    SourceSite5xxError: {
+        "code": "source_site_5xx",
+        "message": "Source site crapped the bed. Try again later.",
+    },
+    SourceSiteUnexpectedError: {
+        "code": "source_site_unexpected",
+        "message": "Unexpected error when requesting source site.",
+    },
+}
+
+
+def handle_source_site_errors(format="json"):
+    assert format in ("json", "html"), f"{format} ain't no format I ever heard of!"
+
+    def outer_func(f):
+        @wraps(f)
+        def decorated_function(*args, **kwargs):
+            try:
+                return f(*args, **kwargs)
+            except SourceSiteResponseError as err:
+                resp = SOURCE_SITE_ERRORS[err.__class__]
+                resp["detail"] = err.__dict__
+                if format == "json":
+                    return jsonify(resp), 500
+                elif format == "html":
+                    return render_template("error.html", **resp), 500
+
+        return decorated_function
+
+    return outer_func
diff --git a/src/pytaku/main.py b/src/pytaku/main.py
index 9626226..ec0749e 100644
--- a/src/pytaku/main.py
+++ b/src/pytaku/main.py
@@ -10,7 +10,7 @@
 from flask import Flask, jsonify, make_response, render_template, request, url_for
 
 from .conf import config
-from .decorators import process_token
+from .decorators import handle_source_site_errors, process_token
 from .persistence import (
     create_token,
     delete_token,
@@ -209,6 +209,7 @@ def _title(site, title_id, user_id=None):
 
 
 @app.route("/m/<site>/<title_id>")
+@handle_source_site_errors("html")
 def spa_title_view(site, title_id):
     title = _title(site, title_id)
     return render_template(
@@ -222,6 +223,7 @@ def spa_title_view(site, title_id):
 
 
 @app.route("/m/<site>/<title_id>/<chapter_id>")
+@handle_source_site_errors("html")
 def spa_chapter_view(site, title_id, chapter_id):
     chapter = load_chapter(site, title_id, chapter_id)
     if not chapter:
@@ -252,6 +254,7 @@ def spa_chapter_view(site, title_id, chapter_id):
 
 @app.route("/api/title/<site>/<title_id>", methods=["GET"])
 @process_token(required=False)
+@handle_source_site_errors("json")
 def api_title(site, title_id):
     title = _title(site, title_id, user_id=request.user_id)
     return title
@@ -259,6 +262,7 @@ def api_title(site, title_id):
 
 @app.route("/api/chapter/<site>/<title_id>/<chapter_id>", methods=["GET"])
 @process_token(required=False)
+@handle_source_site_errors("json")
 def api_chapter(site, title_id, chapter_id):
     chapter = load_chapter(site, title_id, chapter_id)
     if not chapter:
@@ -382,6 +386,7 @@ def api_search(query):
 
 @app.route("/api/follow", methods=["POST"])
 @process_token(required=True)
+@handle_source_site_errors("json")
 def api_follow():
     should_follow = request.json["follow"]
     site = request.json["site"]
@@ -405,12 +410,18 @@ def api_read():
     if reads:
         for r in reads:
             read(
-                request.user_id, r["site"], r["title_id"], r["chapter_id"],
+                request.user_id,
+                r["site"],
+                r["title_id"],
+                r["chapter_id"],
             )
     if unreads:
         for u in unreads:
             unread(
-                request.user_id, u["site"], u["title_id"], u["chapter_id"],
+                request.user_id,
+                u["site"],
+                u["title_id"],
+                u["chapter_id"],
             )
     # TODO: rewrite read/unread to do bulk updates instead of n+1 queries like these.
     # ... Or maybe not. SQLite doesn't mind.
@@ -422,6 +433,7 @@ def api_read():
 
 @app.route("/api/import", methods=["POST"])
 @process_token(required=True)
+@handle_source_site_errors("json")
 def api_import():
     # check if the post request has the file part
     if "tachiyomi" not in request.files:
diff --git a/src/pytaku/static/js/models.js b/src/pytaku/static/js/models.js
index e683512..e28f991 100644
--- a/src/pytaku/static/js/models.js
+++ b/src/pytaku/static/js/models.js
@@ -81,6 +81,8 @@ const Auth = {
     return m.request(options).catch((err) => {
       if (err.code == 401) {
         Auth.clearCredentials();
+      } else if (err.code == 500) {
+        alert(err.response.message);
       }
       throw err;
     });
diff --git a/src/pytaku/templates/error.html b/src/pytaku/templates/error.html
new file mode 100644
index 0000000..69da5d8
--- /dev/null
+++ b/src/pytaku/templates/error.html
@@ -0,0 +1,17 @@
+{# vim: ft=htmldjango
+#}
+{% extends "spa.html" %}
+{% block title %}{{ message }}{% endblock %}
+{% block body %}
+<div class="content">
+  <h1>{{ message }}</h1>
+  <p>Error code: {{ code }}</p>
+  <h2>Details:</h2>
+  <ul>
+    {% for key, val in detail.items() %}
+    <li>{{ key }}: {{ val }}</li>
+    {% endfor %}
+  </ul>
+
+</div>
+{% endblock %}
diff --git a/src/pytaku/templates/spa.html b/src/pytaku/templates/spa.html
index dabbf90..cc61dc2 100644
--- a/src/pytaku/templates/spa.html
+++ b/src/pytaku/templates/spa.html
@@ -24,6 +24,8 @@
   </head>
 
   <body>
+    {% block body %}
+
     <div id="spa-root">
       <noscript>
         <p>Please enable JavaScript to use Pytaku.</p>
@@ -45,5 +47,7 @@
     <script>const initialState = "{{ initial_state }}";</script>
     <script src="{{ url_for('static', filename='vendored/mithril.min.js') }}"></script>
     <script src="{{ url_for('static', filename='js/main.js') }}" type="module"></script>
+
+    {% endblock %}
   </body>
 </html>