diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 54401c94af..5125730363 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -22,6 +22,7 @@ [app.main.worker] [app.util.dom :as dom] [app.util.globals :as g] + [app.util.http :as http] [app.util.i18n :refer [tr]] [app.util.timers :as ts] [app.util.webapi :as wapi] @@ -1123,6 +1124,15 @@ (and (= (.-name ^js cause) "NotFoundError") (str/includes? message "removeChild"))))) +(defn network-failure->error + "Wrap a raw network TypeError as a connectivity error, so it is reported + as an environment failure instead of an application crash." + [cause] + (ex-info (ex-message cause) + {:type (if (false? (.-onLine ^js g/navigator)) :offline :network) + :hint "unable to perform fetch operation"} + cause)) + (defn- from-plugin? "Check if the error is marked as originating from plugin code. The plugin runtime tracks plugin errors in a WeakMap, which works even @@ -1149,6 +1159,10 @@ (is-ignorable-exception? cause) nil + ;; Raw fetch failures: connectivity, not a crash + (http/network-failure? cause) + (on-error (network-failure->error cause)) + ;; All other errors: show exception page :else @@ -1176,6 +1190,10 @@ (is-ignorable-exception? cause) nil + ;; Raw fetch failures: connectivity, not a crash + (http/network-failure? cause) + (on-error (network-failure->error cause)) + ;; All other errors: show exception page :else (let [data (ex-data cause) diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index 62a8b1cac7..97638f7231 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -146,6 +146,26 @@ :body (.-body ^js response) ::response response}) +;; Messages of the TypeError a failed `fetch` rejects with, or a failed +;; read of its response body. +(def ^:private network-failure-messages + #{"Failed to fetch" ; Chrome + "NetworkError when attempting to fetch resource." ; Firefox + "Load failed" ; Safari + "network error" ; Chrome, body read + "Error in body stream"}) ; Firefox, body read + +(defn network-failure? + "True when the error is the browser's TypeError for a request that never + got an answer. Other TypeErrors, such as reading a body twice, are bugs + and do not match." + [cause] + (and (= (.-name ^js cause) "TypeError") + (let [message (ex-message cause)] + (or (contains? network-failure-messages message) + ;; Newer Chrome versions append the host in parentheses + (str/starts-with? message "Failed to fetch ("))))) + (defn process-response-type [response-type response] (let [native-response (::response response) @@ -156,7 +176,19 @@ :blob (.blob ^js native-response))] (->> (rx/from body) (rx/map (fn [body] - (assoc response :body body)))))) + (assoc response :body body))) + ;; The connection can drop while the body streams in, after + ;; `fetch` already resolved; the browser then rejects the read + ;; with a bare TypeError. Other errors, such as parse failures + ;; (SyntaxError), pass as is. + (rx/catch (fn [cause] + (if (network-failure? cause) + (rx/throw (ex-info (ex-message cause) + {:type :network + :hint "unable to read response body" + :uri (:uri response)} + cause)) + (rx/throw cause))))))) (defn send! [{:keys [response-type] :or {response-type :text} :as params}] diff --git a/frontend/test/frontend_tests/errors_test.cljs b/frontend/test/frontend_tests/errors_test.cljs index c373926680..baa5da1f96 100644 --- a/frontend/test/frontend_tests/errors_test.cljs +++ b/frontend/test/frontend_tests/errors_test.cljs @@ -7,6 +7,7 @@ (ns frontend-tests.errors-test (:require [app.main.errors :as errors] + [app.util.globals :as g] [cljs.test :as t :include-macros true])) (defn- make-error @@ -148,3 +149,33 @@ ;; V8 gives native rejections such as a failed fetch no frames at all. (let [cause (make-error "TypeError" "Failed to fetch" :stack "TypeError: Failed to fetch")] (t/is (false? (errors/is-ignorable-exception? cause)))))) + +;; --------------------------------------------------------------------------- +;; network-failure->error tests +;; --------------------------------------------------------------------------- + +(defn- with-online + "Call `f` while `navigator.onLine` reads `online?`." + [online? f] + (js/Object.defineProperty g/navigator "onLine" + #js {:value online? :configurable true}) + (try + (f) + (finally + (js-delete g/navigator "onLine")))) + +(t/deftest test-network-failure-becomes-environment-error + (t/testing "A raw fetch failure is wrapped as an environment error" + (let [cause (make-error "TypeError" "Failed to fetch") + error (errors/network-failure->error cause)] + (t/is (true? (errors/environment-error? error))) + (t/is (= "Failed to fetch" (ex-message error))) + (t/is (identical? cause (ex-cause error)))))) + +(t/deftest test-network-failure-type-follows-connection + (let [cause (make-error "TypeError" "Failed to fetch") + type #(:type (ex-data (errors/network-failure->error cause)))] + (t/testing "Offline browsers report :offline" + (t/is (= :offline (with-online false type)))) + (t/testing "Online browsers report :network" + (t/is (= :network (with-online true type)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index c7e2d547fa..c96f1e4983 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -132,6 +132,7 @@ [frontend-tests.ui.stroke-menu-test] [frontend-tests.ui.workspace-libraries-test] [frontend-tests.util-clipboard-test] + [frontend-tests.util-http-test] [frontend-tests.util-object-test] [frontend-tests.util-queue-test] [frontend-tests.util-range-tree-test] @@ -283,6 +284,7 @@ 'frontend-tests.ui.stroke-menu-test 'frontend-tests.ui.workspace-libraries-test 'frontend-tests.util-clipboard-test + 'frontend-tests.util-http-test 'frontend-tests.util-object-test 'frontend-tests.util-queue-test 'frontend-tests.util-range-tree-test diff --git a/frontend/test/frontend_tests/util_http_test.cljs b/frontend/test/frontend_tests/util_http_test.cljs new file mode 100644 index 0000000000..e686d4d3a9 --- /dev/null +++ b/frontend/test/frontend_tests/util_http_test.cljs @@ -0,0 +1,75 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.util-http-test + (:require + [app.util.http :as http] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async])) + +(def ^:private uri "https://example.com/assets/file.json") + +(defn- failing-response + "A response map whose native body read rejects with `cause`." + [cause] + {:status 200 + :uri uri + :headers {} + ::http/response #js {:text (fn [] (js/Promise.reject cause))}}) + +(defn- ^:async read-error + "Read the body of `response` as text and resolve with the error raised." + [response] + (try + (await (async/->promise (http/process-response-type :text response))) + nil + (catch :default cause + cause))) + +(t/deftest ^:async body-read-network-failure-is-a-network-error + (let [cause (js/TypeError. "network error") + error (await (read-error (failing-response cause))) + data (ex-data error)] + (t/is (= :network (:type data))) + (t/is (= uri (:uri data))) + (t/is (identical? cause (ex-cause error))))) + +(t/deftest ^:async body-parse-failure-passes-unchanged + (let [cause (js/SyntaxError. "Unexpected token < in JSON") + error (await (read-error (failing-response cause)))] + (t/is (identical? cause error)))) + +(t/deftest ^:async body-read-programming-error-passes-unchanged + (let [cause (js/TypeError. "body stream already read") + error (await (read-error (failing-response cause)))] + (t/is (identical? cause error)))) + +;; --------------------------------------------------------------------------- +;; network-failure? tests +;; --------------------------------------------------------------------------- + +(defn- make-error + [error-name message] + (let [err (js/Error. message)] + (set! (.-name err) error-name) + err)) + +(t/deftest test-network-failure-browser-messages + (t/testing "The TypeError each browser gives a failed fetch is a network failure" + (doseq [message ["Failed to fetch" + "Failed to fetch (design.penpot.app)" + "NetworkError when attempting to fetch resource." + "Load failed" + "network error" + "Error in body stream"]] + (t/is (true? (http/network-failure? (make-error "TypeError" message))) + message)))) + +(t/deftest test-network-failure-needs-type-error + (t/testing "Other errors with the same message are not network failures" + (t/is (false? (http/network-failure? (make-error "Error" "Failed to fetch"))))) + (t/testing "Other TypeErrors are not network failures" + (t/is (false? (http/network-failure? (make-error "TypeError" "x is not a function"))))))