TL;DR: Can I influence the retry behavior of fetch in JavaScript? When the server returns a 408, are the browser-induced retries I see implementation-defined or defined somewhere in a web standard?
I am using fetch to POST to my own server.
The full simplified server and client code is attached at the end.
Overall, my protocol is as follows:
- Client sends a
POSTrequest to/foobarand waits for an answer to this very request, ... - This waiting can take very long, ...
- The server does not like if those requests run for too long, so the server replies with a
408 Request Timeoutafter 15 seconds. - The client retries; back to step (1).
Here is my Golang server request handler:
func foobar(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
w.Header().Set("Content-Type", "application/json")
log.Printf("now waiting ...")
select {
case <-ctx.Done():
reason := fmt.Sprintf("%v", ctx.Err())
if errors.Is(ctx.Err(), context.Canceled) {
reason += ", client closed connection"
}
log.Printf("waiting: ctx.Done (reason: %v)", reason)
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
http.Error(w, "yolo, please retry", http.StatusRequestTimeout)
}
return
}
}
This seems to work quite well. Here are the logs of my simplified server. We see that the POST to /foobar is retried roughly every 15 seconds, exactly as desired:
19:16:39 HTTP/1.1 POST request to /foobar
19:16:39 now waiting ...
19:16:54 waiting: ctx.Done (reason: context deadline exceeded)
19:16:54 HTTP/1.1 POST request to /foobar
19:16:54 now waiting ...
19:17:09 waiting: ctx.Done (reason: context deadline exceeded)
19:17:09 HTTP/1.1 POST request to /foobar
19:17:09 now waiting ...
19:17:24 waiting: ctx.Done (reason: context deadline exceeded)
19:17:24 HTTP/1.1 POST request to /foobar
19:17:24 now waiting ...
19:17:39 waiting: ctx.Done (reason: context deadline exceeded)
19:17:39 HTTP/1.1 POST request to /foobar
19:17:39 now waiting ...
19:17:54 waiting: ctx.Done (reason: context deadline exceeded)
My browsers are Firefox 99.
Here is my client-side JavaScript code which wants to try to POST to /foobar indefinitely, as long as the server returns the 408:
(async () => {
const url = `http://localhost:8080/foobar`;
const postFoo = async () => {
let attempt = 1;
let response;
while (true) {
console.log(`POSTing my foo to ${url} (attempt ${attempt})`);
response = await fetch(url, {
method: 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'omit',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
'foo': "foo"
})
});
console.log(`POST ${url}: ${response.statusText}`);
if (response.ok) {
console.log(`response ok`);
break;
}
if (response.status == 408) {
console.log(`retry`);
attempt += 1;
continue;
}
const e = `error talking to ${url}: ${response.statusText} (${response.status})`;
throw e;
}
return response.json();
};
return postFoo()
.then(data => {
console.log(`got answer: JSON for ${url}: ${data}`);
})
.catch((e) => {
console.error(`posting offer error`, e);
});
})();
Expected behavior would be that I see logging output in the JavaScript console similar to the following
time:0s POSTing my foo to http://localhost:8080/foobar (attempt 1)
time:15s POSTing my foo to http://localhost:8080/foobar (attempt 2)
time:30s POSTing my foo to http://localhost:8080/foobar (attempt 3)
time:45s POSTing my foo to http://localhost:8080/foobar (attempt 4)
...
Observed behavior is the following output (Firefox)
POSTing my foo to http://localhost:8080/foobar (attempt 1)
[no further output, and then, after 6.75mins]
> XHR POSThttp://localhost:8080/foobar [HTTP/1.1 408 Request Timeout 405088ms]
ERROR: Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8080/foobar. (Reason: CORS request did not succeed). Status code: (null).
ERROR: posting offer error TypeError: NetworkError when attempting to fetch resource.
My JavaScript while (true) loop only performs one iteration. The browser seems to retry those POST requests independently.
Is this behavior by the browser specified somewhere? Can I configure the browser somehow that I don't want the browser to retry, but that I want to handle the retry myself?
In Chrome beta (version 101 at the time of this writing), I see the following output:
POSTing my foo to http://localhost:8080/foobar (attempt 1)
[after 15 seconds]
POST http://localhost:8080/foobar 408 (Request Timeout)
POST http://localhost:8080/foobar: Request Timeout
retry
POSTing my foo to http://localhost:8080/foobar (attempt 2)
[after 15 seconds]
POST http://localhost:8080/foobar 408 (Request Timeout)
POST http://localhost:8080/foobar: Request Timeout
retry
POSTing my foo to http://localhost:8080/foobar (attempt 3)
[after 15 seconds]
POST http://localhost:8080/foobar 408 (Request Timeout)
POST http://localhost:8080/foobar: Request Timeout
retry
POSTing my foo to http://localhost:8080/foobar (attempt 4)
...
This is exactly what I would expect.
Server code
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
)
func foobar(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
w.Header().Set("Content-Type", "application/json")
log.Printf("now waiting ...")
select {
case <-ctx.Done():
reason := fmt.Sprintf("%v", ctx.Err())
if errors.Is(ctx.Err(), context.Canceled) {
reason += ", client closed connection"
}
log.Printf("waiting: ctx.Done (reason: %v)", reason)
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
http.Error(w, "yolo, please retry", http.StatusRequestTimeout)
}
return
}
}
func main() {
port := os.Getenv("PORT") // Heroku
if port == "" {
port = "8080"
}
fmt.Printf("Hello, world, starting to serve on %s", port)
http.Handle("/foobar", &corsHTTPHandler{[]string{"POST"}, foobar})
log.Fatal(http.ListenAndServe(":"+port, nil))
}
type corsHTTPHandler struct {
wantMethod []string
serve func(w http.ResponseWriter, r *http.Request)
}
func (h *corsHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s request to %s", r.Proto, r.Method, r.URL)
if err := corsAllow(w, r, h.wantMethod); err != nil {
return
}
h.serve(w, r)
}
var stop = errors.New("stop")
func contains(needle string, haystack []string) bool {
for _, s := range haystack {
if needle == s {
return true
}
}
return false
}
func corsAllow(w http.ResponseWriter, r *http.Request, methods []string) error {
if r.Method == "OPTIONS" {
// preflight cors.
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Methods", strings.Join(methods, ", "))
return stop
}
if !contains(r.Method, methods) {
http.Error(w, "not accepting this method", http.StatusBadRequest)
return stop
}
ct, ok := r.Header["Content-Type"]
if !ok {
http.Error(w, "Yo peasant, what about setting Content-Type?", http.StatusBadRequest)
return stop
}
if len(ct) != 1 {
http.Error(w, `got unexpected amount of Content-Type`, http.StatusBadRequest)
return stop
}
if ct[0] != "application/json" {
http.Error(w, `want Content-Type "application/json"`, http.StatusBadRequest)
return stop
}
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
return nil
}