diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 54401c94af..6c0fc30666 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -1123,6 +1123,35 @@ (and (= (.-name ^js cause) "NotFoundError") (str/includes? message "removeChild"))))) +;; 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 + +(defn network-failure? + "True when the error is the browser's TypeError for a request that never + got an answer. `app.util.http` wraps its own failures as `:network`; this + catches the raw ones from third-party code (zip.js, plugins, schema + loads) that reach the global handlers." + [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 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 +1178,10 @@ (is-ignorable-exception? cause) nil + ;; Raw fetch failures: connectivity, not a crash + (network-failure? cause) + (on-error (network-failure->error cause)) + ;; All other errors: show exception page :else @@ -1176,6 +1209,10 @@ (is-ignorable-exception? cause) nil + ;; Raw fetch failures: connectivity, not a crash + (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..aec75d9f41 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -156,7 +156,18 @@ :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. Parse failures (SyntaxError) pass as is. + (rx/catch (fn [cause] + (if (= (.-name ^js cause) "TypeError") + (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..e8ba2bd228 100644 --- a/frontend/test/frontend_tests/errors_test.cljs +++ b/frontend/test/frontend_tests/errors_test.cljs @@ -148,3 +148,32 @@ ;; 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? tests +;; --------------------------------------------------------------------------- + +(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"]] + (t/is (true? (errors/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? (errors/network-failure? (make-error "Error" "Failed to fetch"))))) + (t/testing "Other TypeErrors are not network failures" + (t/is (false? (errors/network-failure? (make-error "TypeError" "x is not a function")))))) + +(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 (contains? #{:network :offline} (:type (ex-data error)))) + (t/is (true? (errors/environment-error? error))) + (t/is (= "Failed to fetch" (ex-message error))) + (t/is (identical? cause (ex-cause error)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 8cea58e9b6..f22bcfa9f8 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -129,6 +129,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] @@ -277,6 +278,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..b20df426d7 --- /dev/null +++ b/frontend/test/frontend_tests/util_http_test.cljs @@ -0,0 +1,43 @@ +;; 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] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true])) + +(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- read-error + "Read the body of `response` as text and resolve with the error raised." + [response] + (js/Promise. + (fn [resolve] + (rx/subs! (fn [_] (resolve nil)) + resolve + (http/process-response-type :text response))))) + +(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))))