Retrieving my public IP address with the javascript Fetch API (no jquery, no json)
One of the main reasons Promises were created was to avoid the ugly nesting of callbacks when multiple asynchronous actions need to occur. For example, rather than

fn1(dataToSend, dataForFn2 =&gt; {
  fn2(dataForFn2, dataForFn3 =&gt; {
    fn3(dataForFn3, processedData =&gt; {
      console.log(processedData);
    });
  });
});


If the functions returned Promises instead of accepting callbacks, the above could be done with

fn1(dataToSend)
  .then(fn2)
  .then(fn3)
  .then(processedData =&gt; {
    console.log(processedData);
  });


The Promise-as-callback antipattern is when you nest .thens in other .thens or pass around callbacks rather than chaining another .then onto the previous Promise. This is the trap you're falling into here. Since fetch returns a Promise itself, it would be best to return that Promise and allow consumers of this script to use it. That is, it would be really convenient (and appropriate) if consumers to be able to do something like

get_ip(IP_URLs)
  .then(console.log)
  .catch(handleErrors);


Another related nesting issue you might consider is, instead of

if (goodCondition) {
  // lots of code
} else {
  callback("No more URLs");
}


Rather than having to keep track of which brackets correspond to what upper condition, it might be preferable to return early instead:

if (!goodCondition) {
  callback("No more URLs");
  return;
}
// lots of code


But if you return the Promises instead of using callbacks, this should cease to be an issue.

The init object you're constructing seems strangely complicated. Many of the properties you're passing are the default ones already (like method: 'get', redirect: 'follow') or useless (credentials: 'same-origin'). Unless you're deliberately trying to affect some things with the object, it would be simpler and less confusing to omit it entirely (the script looks to work just fine without it, none of the properties look useful).

You have a lot of fail-handlers. If you really want to exhaustively identify what's OK and what isn't, it would be good to also check if the response is OK too:

return fetch(url).then(
  response =&gt; {
    if (!response.ok) {
      console.log('Response was not OK:', response.status);
      return getIPFromNextSite(urls);
    }


But unless you're actively analyzing exactly which stage is working and what isn't, and really need all those .fail handlers, you might consider avoiding them and .catching errors at the end instead:

function getIPFromNextSite(urls) {
  if (!urls.length) {
    // Send control flow to the consuming .catch block:
    throw new Error('No more URLs');
  }
  const url = urls.pop();
  return fetch(url)
    .then(response =&gt; response.text())
    .then((txt) =&gt; {
      if (isValidIP(txt)) {
        // Send control flow to the consuming .then block:
        return txt;
      } else {
        return getIPFromNextSite(urls);
      }
    })
    .catch((err) =&gt; {
      return getIPFromNextSite(urls);
    });
}


It's only a comment in your code, but .slice does not deep copy arrays - it only shallow copies them. (Since the array here contains immutable strings, a shallow copy is fine anyway.)

If you want to follow the standard Javascript naming conventions, you could consider using camelCase for functions and most variables by default.

When declaring variables, best to always use const. Using let warns readers of the code that you may reassign the variable name, which results in more cognitive overhead (and is confusing if there isn't actually any chance of the name being reassigned).

In full, this is how I would do it:



const IP_URLs = [
  'https://api.ipify.org/',
  'https://ipecho.net/plain',
  'https://api.kwelo.com/v1/network/ip-address/my',
  'https://myexternalip.com/raw',
];
function getIPFromNextSite(urls) {
  if (!urls.length) {
    // Send control flow to the consuming .catch block:
    throw new Error('No more URLs');
  }
  const url = urls.pop();
  return fetch(url)
    .then(response =&gt; response.text())
    .then((txt) =&gt; {
      if (isValidIP(txt)) {
        // Send control flow to the consuming .then block:
        return txt;
      } else {
        return getIPFromNextSite(urls);
      }
    })
    .catch((err) =&gt; {
      return getIPFromNextSite(urls);
    });
}
function isValidIP(txt) {
  return /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|$)){4}$/.test(txt);
}
const getIP = urls =&gt; getIPFromNextSite(urls.slice());

getIP(IP_URLs).then(console.log);





  Should I have used async/await?


You could. If you need to exhaustively check every error path, it would require a lot of ugly try/catch boilerplate, but if you just want a result, it looks moderately cleaner than the version above:



const IP_URLs = [
  'https://api.ipify.org/',
  'https://ipecho.net/plain',
  'https://api.kwelo.com/v1/network/ip-address/my',
  'https://myexternalip.com/raw',
];
async function getIPFromNextSite(urls) {
  for (const url of urls) {
    try {
      const response = await fetch(url);
      const text = await response.text();
      if (isValidIP(text)) {
        // Send control flow to the consuming .then block:
        return text;
      }
    } catch(e) {
      // Don't do anything, just continue on to next iteration
    }
  }
  // Send control flow to the consuming .catch block:
  throw new Error('No more URLs');
}
function isValidIP(txt) {
  return /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|$)){4}$/.test(txt);
}
const getIP = urls =&gt; getIPFromNextSite(urls);

getIP(IP_URLs).then(console.log);