mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🐛 Report raw fetch failures as connectivity errors
A fetch that fails outside app.util.http (zip.js, plugins, schema loads) rejects with a bare TypeError such as "Failed to fetch". It reached the global handlers and was reported as an unhandled crash, with no stack and no URL. The global handlers now wrap the browser's network TypeErrors as :network (or :offline when the browser says so), so they get the connection toast and a compact handled report. Response body reads in app.util.http also wrap a TypeError as :network with the request uri, as fetch itself already did. Relates to #12581 AI-assisted-by: claude-opus-5-5
This commit is contained in:
parent
a0ef927faa
commit
933d5e31f2
@ -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)
|
||||
|
||||
@ -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}]
|
||||
|
||||
@ -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))))))
|
||||
|
||||
@ -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
|
||||
|
||||
43
frontend/test/frontend_tests/util_http_test.cljs
Normal file
43
frontend/test/frontend_tests/util_http_test.cljs
Normal file
@ -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))))
|
||||
Loading…
x
Reference in New Issue
Block a user