diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs
index 057605a5a2..f9ddc8d9d7 100644
--- a/frontend/src/debug.cljs
+++ b/frontend/src/debug.cljs
@@ -374,6 +374,87 @@
(.text response))
(p/then apply-changes)))
+(def ^:private simulated-error-body
+ "Stands in for the error page a proxy or CDN writes when it answers instead
+ of the backend."
+ "
Simulated errorsimulated intermediary response")
+
+(defonce ^:private pending-http-error (atom nil))
+
+(defn- simulated-status
+ "Status to answer `uri` with, or nil to let the request through. A
+ simulation without a deadline answers one call; one with a deadline answers
+ every matching call until it runs out."
+ [uri]
+ (let [{:keys [method status until]} @pending-http-error]
+ (when (and (some? method)
+ (str/includes? uri (str "/api/main/methods/" method)))
+ (cond
+ (nil? until) (do (reset! pending-http-error nil) status)
+ (< (js/Date.now) until) status
+ :else (do (reset! pending-http-error nil) nil)))))
+
+;; The original fetch and the wrapper standing in for it, or nil when unhooked.
+(defonce ^:private http-error-hook (atom nil))
+
+(defn- install-http-error-hook!
+ []
+ (when (nil? @http-error-hook)
+ (let [original (unchecked-get js/globalThis "fetch")
+ wrapper (fn [input params]
+ (let [uri (if (string? input) input (unchecked-get input "url"))]
+ (if-let [status (simulated-status uri)]
+ (p/resolved (js/Response. simulated-error-body
+ #js {:status status
+ :headers #js {"content-type" "text/html"}}))
+ (.call original js/globalThis input params))))]
+ (reset! http-error-hook {:original original :wrapper wrapper})
+ (unchecked-set js/globalThis "fetch" wrapper))))
+
+(defn- remove-http-error-hook!
+ []
+ (when-let [{:keys [original wrapper]} @http-error-hook]
+ ;; Restore only while the wrapper is still installed, so a later
+ ;; wrapper survives.
+ (when (identical? wrapper (unchecked-get js/globalThis "fetch"))
+ (unchecked-set js/globalThis "fetch" original))
+ (reset! http-error-hook nil)))
+
+(defn ^:export simulateHttpError
+ "Answers calls to an API method with `status` and a body that carries no
+ Penpot error code, the way a CDN or a corporate proxy does. Every other
+ request goes through untouched.
+
+ Without `seconds` the simulation is spent once it answers one call. With
+ `seconds` it answers every matching call for that long, which is how to
+ watch a save retry, warn, keep retrying and finally recover.
+
+ debug.simulateHttpError('update-file')
+ debug.simulateHttpError('get-comment-threads', 403)
+ debug.simulateHttpError('update-file', 524, 120)"
+ ([method]
+ (simulateHttpError method 524 nil))
+ ([method status]
+ (simulateHttpError method status nil))
+ ([method status seconds]
+ (install-http-error-hook!)
+ (let [until (when (and (number? seconds) (pos? seconds))
+ (+ (js/Date.now) (* 1000 seconds)))]
+ (reset! pending-http-error {:method method
+ :status status
+ :until until})
+ (if (some? until)
+ (str "every " method " call will be answered with " status
+ " for the next " seconds " seconds")
+ (str "the next " method " call will be answered with " status)))))
+
+(defn ^:export clearHttpErrorSimulation
+ "Stops a running simulation and takes the wrapper off `fetch`."
+ []
+ (reset! pending-http-error nil)
+ (remove-http-error-hook!)
+ "requests reach the backend again")
+
(defn ^:export reset-viewport
[]
(st/emit!