Fossil SCM

Internal doc updates in fossil.fetch(). Ensure that fossil.fetch()'s onerror()/ontimeout() handler do not propagate exceptions (a defensive measure, not a fix for a known bug).

stephan 2025-04-11 22:48 trunk
Commit 1d3db5050fead708460733621a9860dcdad8352ec987172dd9cf62485f1212d0
1 file changed +61 -29
--- src/fossil.fetch.js
+++ src/fossil.fetch.js
@@ -27,36 +27,51 @@
2727
"this", noting that this call may have amended the options object
2828
with state other than what the caller provided.
2929
3030
- onerror: callback(Error object) (default = output error message
3131
to console.error() and fossil.error()). Triggered if the request
32
- generates any response other than HTTP 200, suffers a connection
33
- error or timeout while awaiting a response, or if the onload()
34
- handler throws an exception. In the context of the callback, the
35
- options object is "this". Note that this function is intended to be
36
- used solely for error reporting, not error recovery. Because
37
- onerror() may be called if onload() throws, it is up to the caller
38
- to ensure that their onerror() callback references only state which
39
- is valid in such a case. Special cases for the Error object: (1) If
40
- the connection times out via XHR.ontimeout(), the error object will
41
- have its (.name='timeout', .status=XHR.status) set. (2) Else if it
42
- gets a non 2xx HTTP code then it will have
43
- (.name='http',.status=XHR.status). (3) If it was proxied through a
44
- JSON-format exception on the server, it will have
45
- (.name='json',status=XHR.status).
32
+ generates any response other than HTTP 200, or if the beforesend()
33
+ or onload() handler throws an exception. In the context of the
34
+ callback, the options object is "this". This function is intended
35
+ to be used solely for error reporting, not error recovery. Special
36
+ cases for the Error object:
37
+
38
+ 1. Timeouts unfortunately show up as a series of 2 events: an
39
+ HTTP 0 followed immediately by an XHR.ontimeout(). The former
40
+ cannot(?) be unambiguously identified as the trigger for the
41
+ pending timeout, so we have no option but to pass it on as-is
42
+ instead of flagging it as a timeout response. The latter will
43
+ trigger the client-provided ontimeout() if it's available (see
44
+ below), else it calls the onerror() callback. An error object
45
+ passed to ontimeout() by fetch() will have (.name='timeout',
46
+ .status=XHR.status).
47
+
48
+ 2. Else if the response contains a JSON-format exception on the
49
+ server, it will have (.name='json-error',
50
+ status=XHR.status). Any JSON-format result object which has a
51
+ property named "error" is considered to be a server-generated
52
+ error.
53
+
54
+ 3. Else if it gets a non 2xx HTTP code then it will have
55
+ (.name='http',.status=XHR.status).
56
+
57
+ 4. If onerror() throws, the exception is suppressed but may
58
+ generate a console error message.
4659
4760
- ontimeout: callback(Error object). If set, timeout errors are
4861
reported here, else they are reported through onerror().
4962
Unfortunately, XHR fires two events for a timeout: an
5063
onreadystatechange() and an ontimeout(), in that order. From the
5164
former, however, we cannot unambiguously identify the error as
5265
having been caused by a timeout, so clients which set ontimeout()
53
- will get _two_ callback calls: one with noting HTTP 0 response
66
+ will get _two_ callback calls: one with with an HTTP error response
5467
followed immediately by an ontimeout() response. Error objects
55
- thown passed to this will have (.name='timeout') and
56
- (.status=xhr.HttpStatus). In the context of the callback, the
57
- options object is "this",
68
+ passed to this will have (.name='timeout', .status=xhr.HttpStatus).
69
+ In the context of the callback, the options object is "this", Like
70
+ onerror(), any exceptions thrown by the ontimeout() handler are
71
+ suppressed, but may generate a console error message. The onerror()
72
+ handler is _not_ called in this case.
5873
5974
- method: 'POST' | 'GET' (default = 'GET'). CASE SENSITIVE!
6075
6176
- payload: anything acceptable by XHR2.send(ARG) (DOMString,
6277
Document, FormData, Blob, File, ArrayBuffer), or a plain object or
@@ -64,11 +79,12 @@
6479
then the method is automatically set to 'POST'. By default XHR2
6580
will set the content type based on the payload type. If an
6681
object/array is converted to JSON, the contentType option is
6782
automatically set to 'application/json', and if JSON.stringify() of
6883
that value fails then the exception is propagated to this
69
- function's caller.
84
+ function's caller. (beforesend(), aftersend(), and onerror() are
85
+ NOT triggered in that case.)
7086
7187
- contentType: Optional request content type when POSTing. Ignored
7288
if the method is not 'POST'.
7389
7490
- responseType: optional string. One of ("text", "arraybuffer",
@@ -76,10 +92,13 @@
7692
As an extension, it supports "json", which tells it that the
7793
response is expected to be text and that it should be JSON.parse()d
7894
before passing it on to the onload() callback. If parsing of such
7995
an object fails, the onload callback is not called, and the
8096
onerror() callback is passed the exception from the parsing error.
97
+ If the parsed JSON object has an "error" property, it is assumed to
98
+ be an error string, which is used to populate a new Error object,
99
+ which will gets (.name="json") set on it.
81100
82101
- urlParams: string|object. If a string, it is assumed to be a
83102
URI-encoded list of params in the form "key1=val1&key2=val2...",
84103
with NO leading '?'. If it is an object, all of its properties get
85104
converted to that form. Either way, the parameters get appended to
@@ -91,11 +110,11 @@
91110
value of that single header. If it's an array, it's treated as a
92111
list of headers to return, and the 2nd argument is a map of those
93112
header values. When a map is passed on, all of its keys are
94113
lower-cased. When a given header is requested and that header is
95114
set multiple times, their values are (per the XHR docs)
96
- concatenated together with ", " between them.
115
+ concatenated together with "," between them.
97116
98117
- beforesend/aftersend: optional callbacks which are called
99118
without arguments immediately before the request is submitted
100119
and immediately after it is received, regardless of success or
101120
error. In the context of the callback, the options object is
@@ -151,11 +170,11 @@
151170
});
152171
return rc;
153172
};
154173
}
155174
if('/'===uri[0]) uri = uri.substr(1);
156
- if(!opt) opt = {};
175
+ if(!opt) opt = {}/* should arguably be Object.create(null) */;
157176
else if('function'===typeof opt) opt={onload:opt};
158177
if(!opt.onload) opt.onload = f.onload;
159178
if(!opt.onerror) opt.onerror = f.onerror;
160179
if(!opt.beforesend) opt.beforesend = f.beforesend;
161180
if(!opt.aftersend) opt.aftersend = f.aftersend;
@@ -188,14 +207,28 @@
188207
try{opt.aftersend()}catch(e){/*ignore*/}
189208
const err = new Error("XHR timeout of "+x.timeout+"ms expired.");
190209
err.status = x.status;
191210
err.name = 'timeout';
192211
//console.warn("fetch.ontimeout",ev);
193
- (opt.ontimeout || opt.onerror)(err);
212
+ try{
213
+ (opt.ontimeout || opt.onerror)(err);
214
+ }catch(e){
215
+ /*ignore*/
216
+ console.error("fossil.fetch()'s ontimeout() handler threw",e);
217
+ }
218
+ };
219
+ /* Ensure that if onerror() throws, it's ignored. */
220
+ const origOnError = opt.onerror;
221
+ opt.onerror = (arg)=>{
222
+ try{ origOnError.call(this, arg) }
223
+ catch(e){
224
+ /*ignored*/
225
+ console.error("fossil.fetch()'s onerror() threw",e);
226
+ }
194227
};
195228
x.onreadystatechange = function(ev){
196
- //console.warn("onreadystatechange", ev.target);
229
+ //console.warn("onreadystatechange", x.readyState, ev.target.responseText);
197230
if(XMLHttpRequest.DONE !== x.readyState) return;
198231
try{opt.aftersend()}catch(e){/*ignore*/}
199232
if(false && 0===x.status){
200233
/* For reasons unknown, we _sometimes_ trigger x.status==0 in FF
201234
when the /chat page starts up, but not in Chrome nor in other
@@ -249,24 +282,23 @@
249282
try{
250283
const args = [(jsonResponse && x.response)
251284
? JSON.parse(x.response) : x.response];
252285
if(head) args.push(head);
253286
opt.onload.apply(opt, args);
254
- }catch(e){
255
- opt.onerror(e);
287
+ }catch(err){
288
+ opt.onerror(err);
256289
}
257
- };
290
+ }/*onreadystatechange()*/;
258291
try{opt.beforesend()}
259
- catch(e){
260
- opt.onerror(e);
292
+ catch(err){
293
+ opt.onerror(err);
261294
return;
262295
}
263296
x.open(opt.method||'GET', url.join(''), true);
264297
if('POST'===opt.method && 'string'===typeof opt.contentType){
265298
x.setRequestHeader('Content-Type',opt.contentType);
266299
}
267
- x.hasExplicitTimeout = !!(+opt.timeout);
268300
x.timeout = +opt.timeout || f.timeout;
269301
if(undefined!==payload) x.send(payload);
270302
else x.send();
271303
return this;
272304
};
273305
--- src/fossil.fetch.js
+++ src/fossil.fetch.js
@@ -27,36 +27,51 @@
27 "this", noting that this call may have amended the options object
28 with state other than what the caller provided.
29
30 - onerror: callback(Error object) (default = output error message
31 to console.error() and fossil.error()). Triggered if the request
32 generates any response other than HTTP 200, suffers a connection
33 error or timeout while awaiting a response, or if the onload()
34 handler throws an exception. In the context of the callback, the
35 options object is "this". Note that this function is intended to be
36 used solely for error reporting, not error recovery. Because
37 onerror() may be called if onload() throws, it is up to the caller
38 to ensure that their onerror() callback references only state which
39 is valid in such a case. Special cases for the Error object: (1) If
40 the connection times out via XHR.ontimeout(), the error object will
41 have its (.name='timeout', .status=XHR.status) set. (2) Else if it
42 gets a non 2xx HTTP code then it will have
43 (.name='http',.status=XHR.status). (3) If it was proxied through a
44 JSON-format exception on the server, it will have
45 (.name='json',status=XHR.status).
 
 
 
 
 
 
 
 
 
 
 
 
 
46
47 - ontimeout: callback(Error object). If set, timeout errors are
48 reported here, else they are reported through onerror().
49 Unfortunately, XHR fires two events for a timeout: an
50 onreadystatechange() and an ontimeout(), in that order. From the
51 former, however, we cannot unambiguously identify the error as
52 having been caused by a timeout, so clients which set ontimeout()
53 will get _two_ callback calls: one with noting HTTP 0 response
54 followed immediately by an ontimeout() response. Error objects
55 thown passed to this will have (.name='timeout') and
56 (.status=xhr.HttpStatus). In the context of the callback, the
57 options object is "this",
 
 
58
59 - method: 'POST' | 'GET' (default = 'GET'). CASE SENSITIVE!
60
61 - payload: anything acceptable by XHR2.send(ARG) (DOMString,
62 Document, FormData, Blob, File, ArrayBuffer), or a plain object or
@@ -64,11 +79,12 @@
64 then the method is automatically set to 'POST'. By default XHR2
65 will set the content type based on the payload type. If an
66 object/array is converted to JSON, the contentType option is
67 automatically set to 'application/json', and if JSON.stringify() of
68 that value fails then the exception is propagated to this
69 function's caller.
 
70
71 - contentType: Optional request content type when POSTing. Ignored
72 if the method is not 'POST'.
73
74 - responseType: optional string. One of ("text", "arraybuffer",
@@ -76,10 +92,13 @@
76 As an extension, it supports "json", which tells it that the
77 response is expected to be text and that it should be JSON.parse()d
78 before passing it on to the onload() callback. If parsing of such
79 an object fails, the onload callback is not called, and the
80 onerror() callback is passed the exception from the parsing error.
 
 
 
81
82 - urlParams: string|object. If a string, it is assumed to be a
83 URI-encoded list of params in the form "key1=val1&key2=val2...",
84 with NO leading '?'. If it is an object, all of its properties get
85 converted to that form. Either way, the parameters get appended to
@@ -91,11 +110,11 @@
91 value of that single header. If it's an array, it's treated as a
92 list of headers to return, and the 2nd argument is a map of those
93 header values. When a map is passed on, all of its keys are
94 lower-cased. When a given header is requested and that header is
95 set multiple times, their values are (per the XHR docs)
96 concatenated together with ", " between them.
97
98 - beforesend/aftersend: optional callbacks which are called
99 without arguments immediately before the request is submitted
100 and immediately after it is received, regardless of success or
101 error. In the context of the callback, the options object is
@@ -151,11 +170,11 @@
151 });
152 return rc;
153 };
154 }
155 if('/'===uri[0]) uri = uri.substr(1);
156 if(!opt) opt = {};
157 else if('function'===typeof opt) opt={onload:opt};
158 if(!opt.onload) opt.onload = f.onload;
159 if(!opt.onerror) opt.onerror = f.onerror;
160 if(!opt.beforesend) opt.beforesend = f.beforesend;
161 if(!opt.aftersend) opt.aftersend = f.aftersend;
@@ -188,14 +207,28 @@
188 try{opt.aftersend()}catch(e){/*ignore*/}
189 const err = new Error("XHR timeout of "+x.timeout+"ms expired.");
190 err.status = x.status;
191 err.name = 'timeout';
192 //console.warn("fetch.ontimeout",ev);
193 (opt.ontimeout || opt.onerror)(err);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194 };
195 x.onreadystatechange = function(ev){
196 //console.warn("onreadystatechange", ev.target);
197 if(XMLHttpRequest.DONE !== x.readyState) return;
198 try{opt.aftersend()}catch(e){/*ignore*/}
199 if(false && 0===x.status){
200 /* For reasons unknown, we _sometimes_ trigger x.status==0 in FF
201 when the /chat page starts up, but not in Chrome nor in other
@@ -249,24 +282,23 @@
249 try{
250 const args = [(jsonResponse && x.response)
251 ? JSON.parse(x.response) : x.response];
252 if(head) args.push(head);
253 opt.onload.apply(opt, args);
254 }catch(e){
255 opt.onerror(e);
256 }
257 };
258 try{opt.beforesend()}
259 catch(e){
260 opt.onerror(e);
261 return;
262 }
263 x.open(opt.method||'GET', url.join(''), true);
264 if('POST'===opt.method && 'string'===typeof opt.contentType){
265 x.setRequestHeader('Content-Type',opt.contentType);
266 }
267 x.hasExplicitTimeout = !!(+opt.timeout);
268 x.timeout = +opt.timeout || f.timeout;
269 if(undefined!==payload) x.send(payload);
270 else x.send();
271 return this;
272 };
273
--- src/fossil.fetch.js
+++ src/fossil.fetch.js
@@ -27,36 +27,51 @@
27 "this", noting that this call may have amended the options object
28 with state other than what the caller provided.
29
30 - onerror: callback(Error object) (default = output error message
31 to console.error() and fossil.error()). Triggered if the request
32 generates any response other than HTTP 200, or if the beforesend()
33 or onload() handler throws an exception. In the context of the
34 callback, the options object is "this". This function is intended
35 to be used solely for error reporting, not error recovery. Special
36 cases for the Error object:
37
38 1. Timeouts unfortunately show up as a series of 2 events: an
39 HTTP 0 followed immediately by an XHR.ontimeout(). The former
40 cannot(?) be unambiguously identified as the trigger for the
41 pending timeout, so we have no option but to pass it on as-is
42 instead of flagging it as a timeout response. The latter will
43 trigger the client-provided ontimeout() if it's available (see
44 below), else it calls the onerror() callback. An error object
45 passed to ontimeout() by fetch() will have (.name='timeout',
46 .status=XHR.status).
47
48 2. Else if the response contains a JSON-format exception on the
49 server, it will have (.name='json-error',
50 status=XHR.status). Any JSON-format result object which has a
51 property named "error" is considered to be a server-generated
52 error.
53
54 3. Else if it gets a non 2xx HTTP code then it will have
55 (.name='http',.status=XHR.status).
56
57 4. If onerror() throws, the exception is suppressed but may
58 generate a console error message.
59
60 - ontimeout: callback(Error object). If set, timeout errors are
61 reported here, else they are reported through onerror().
62 Unfortunately, XHR fires two events for a timeout: an
63 onreadystatechange() and an ontimeout(), in that order. From the
64 former, however, we cannot unambiguously identify the error as
65 having been caused by a timeout, so clients which set ontimeout()
66 will get _two_ callback calls: one with with an HTTP error response
67 followed immediately by an ontimeout() response. Error objects
68 passed to this will have (.name='timeout', .status=xhr.HttpStatus).
69 In the context of the callback, the options object is "this", Like
70 onerror(), any exceptions thrown by the ontimeout() handler are
71 suppressed, but may generate a console error message. The onerror()
72 handler is _not_ called in this case.
73
74 - method: 'POST' | 'GET' (default = 'GET'). CASE SENSITIVE!
75
76 - payload: anything acceptable by XHR2.send(ARG) (DOMString,
77 Document, FormData, Blob, File, ArrayBuffer), or a plain object or
@@ -64,11 +79,12 @@
79 then the method is automatically set to 'POST'. By default XHR2
80 will set the content type based on the payload type. If an
81 object/array is converted to JSON, the contentType option is
82 automatically set to 'application/json', and if JSON.stringify() of
83 that value fails then the exception is propagated to this
84 function's caller. (beforesend(), aftersend(), and onerror() are
85 NOT triggered in that case.)
86
87 - contentType: Optional request content type when POSTing. Ignored
88 if the method is not 'POST'.
89
90 - responseType: optional string. One of ("text", "arraybuffer",
@@ -76,10 +92,13 @@
92 As an extension, it supports "json", which tells it that the
93 response is expected to be text and that it should be JSON.parse()d
94 before passing it on to the onload() callback. If parsing of such
95 an object fails, the onload callback is not called, and the
96 onerror() callback is passed the exception from the parsing error.
97 If the parsed JSON object has an "error" property, it is assumed to
98 be an error string, which is used to populate a new Error object,
99 which will gets (.name="json") set on it.
100
101 - urlParams: string|object. If a string, it is assumed to be a
102 URI-encoded list of params in the form "key1=val1&key2=val2...",
103 with NO leading '?'. If it is an object, all of its properties get
104 converted to that form. Either way, the parameters get appended to
@@ -91,11 +110,11 @@
110 value of that single header. If it's an array, it's treated as a
111 list of headers to return, and the 2nd argument is a map of those
112 header values. When a map is passed on, all of its keys are
113 lower-cased. When a given header is requested and that header is
114 set multiple times, their values are (per the XHR docs)
115 concatenated together with "," between them.
116
117 - beforesend/aftersend: optional callbacks which are called
118 without arguments immediately before the request is submitted
119 and immediately after it is received, regardless of success or
120 error. In the context of the callback, the options object is
@@ -151,11 +170,11 @@
170 });
171 return rc;
172 };
173 }
174 if('/'===uri[0]) uri = uri.substr(1);
175 if(!opt) opt = {}/* should arguably be Object.create(null) */;
176 else if('function'===typeof opt) opt={onload:opt};
177 if(!opt.onload) opt.onload = f.onload;
178 if(!opt.onerror) opt.onerror = f.onerror;
179 if(!opt.beforesend) opt.beforesend = f.beforesend;
180 if(!opt.aftersend) opt.aftersend = f.aftersend;
@@ -188,14 +207,28 @@
207 try{opt.aftersend()}catch(e){/*ignore*/}
208 const err = new Error("XHR timeout of "+x.timeout+"ms expired.");
209 err.status = x.status;
210 err.name = 'timeout';
211 //console.warn("fetch.ontimeout",ev);
212 try{
213 (opt.ontimeout || opt.onerror)(err);
214 }catch(e){
215 /*ignore*/
216 console.error("fossil.fetch()'s ontimeout() handler threw",e);
217 }
218 };
219 /* Ensure that if onerror() throws, it's ignored. */
220 const origOnError = opt.onerror;
221 opt.onerror = (arg)=>{
222 try{ origOnError.call(this, arg) }
223 catch(e){
224 /*ignored*/
225 console.error("fossil.fetch()'s onerror() threw",e);
226 }
227 };
228 x.onreadystatechange = function(ev){
229 //console.warn("onreadystatechange", x.readyState, ev.target.responseText);
230 if(XMLHttpRequest.DONE !== x.readyState) return;
231 try{opt.aftersend()}catch(e){/*ignore*/}
232 if(false && 0===x.status){
233 /* For reasons unknown, we _sometimes_ trigger x.status==0 in FF
234 when the /chat page starts up, but not in Chrome nor in other
@@ -249,24 +282,23 @@
282 try{
283 const args = [(jsonResponse && x.response)
284 ? JSON.parse(x.response) : x.response];
285 if(head) args.push(head);
286 opt.onload.apply(opt, args);
287 }catch(err){
288 opt.onerror(err);
289 }
290 }/*onreadystatechange()*/;
291 try{opt.beforesend()}
292 catch(err){
293 opt.onerror(err);
294 return;
295 }
296 x.open(opt.method||'GET', url.join(''), true);
297 if('POST'===opt.method && 'string'===typeof opt.contentType){
298 x.setRequestHeader('Content-Type',opt.contentType);
299 }
 
300 x.timeout = +opt.timeout || f.timeout;
301 if(undefined!==payload) x.send(payload);
302 else x.send();
303 return this;
304 };
305

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button