Timeouts & Cancellation
Goal: Learn how to prevent the program from freezing on slow responses.
Source code: chapter4/src/uptimemon.nim
Our current program works fine with the well-behaving URIs we've tested so far: all these locations either respond quickly or quickly return an error.
However, not all requests will go smoothly when you face the real web. Poor connections, slow servers, anti-bot checks, and access restrictions result in responses that may take long to complete or even never complete. One "misbehaving" request can negatively affect the entire program.
For example, try adding an IP address that never responds to the list:
const uris = @[
"https://duckduckgo.com/?q=chronos", "https://mock.codes/403", "http://10.255.255.1",
]
Run the program and you'll see that it'll run for 10+ seconds, stuck on this last IP.
Let's add a timeout to our requests to cancel slow requests before they ruin our app: if a request takes longer than 5 seconds, we cancel it.
import std/sequtils
import chronos/apps/http/httpclient
const uris = @[
"https://duckduckgo.com/?q=chronos", "https://mock.codes/403", "http://10.255.255.1",
]
proc check(session: HttpSessionRef, uri: string) {.async: (raises: [CancelledError]).} =
try:
let response = await session.fetch(parseUri(uri)).wait(5.seconds)
if response.status == 200:
echo "[OK] " & uri
else:
echo "[NOK] " & uri & ": " & $response.status
except HttpError, FuturePendingError, AsyncTimeoutError:
echo "[ERR] " & uri & ": " & getCurrentExceptionMsg()
proc check(uris: seq[string]) {.async: (raises: []).} =
let
session = HttpSessionRef.new()
futures = uris.mapIt(session.check(it))
try:
await allFutures(futures)
except CancelledError:
await cancelAndWait(futures)
finally:
await session.closeWait()
when isMainModule:
waitFor check(uris)
Here's the part that changed:
proc check(session: HttpSessionRef, uri: string) {.async: (raises: [CancelledError]).} =
try:
let response = await session.fetch(parseUri(uri)).wait(5.seconds)
if response.status == 200:
echo "[OK] " & uri
else:
echo "[NOK] " & uri & ": " & $response.status
except HttpError, FuturePendingError, AsyncTimeoutError:
echo "[ERR] " & uri & ": " & getCurrentExceptionMsg()
- We use the
.wait(timeout)modifier on ourfetchfuture. - If the request takes longer than the provided duration,
.wait()automatically cancels the underlying future and raises anAsyncTimeoutError. - We catch this error alongside other expected exceptions in our
exceptblock.
In Nim, there are several ways to capture the message from an exception:
- using
getCurrentExceptionMsg(), as we do in this tutorial - using
except <Exception> as eand then callinge.msg
Both variants have their advantages and limitations. For example, the as syntax can be used only with one exception type at a time while a lonely except used with getCurrentExceptionMsg() allows to capture multiple exception types in one statement.
On the other hand, because e.msg is guaranteed to capture a particular exception type, it's more deterministic and gives better control over exception handling logic.
The rule of thumb is that when your exception handling is simple (like we have in this tutorial—we simply echo the message regardless of the exception type), getCurrentExceptionMsg() is a simpler, more readable option, but if elaborate exception handling is an essential part of your business logic, you should prefer except <Exception> as e ... e.msg syntax.
Run the program again and you'll see it complete in roughly 5 seconds, i.e. our timeout.
One important thing to notice here is that adding a timeout won't save us from slow DNS resolutions.
Before we can make an HTTP request, we need to resolve the target hostname, i.e. get the IP address that corresponds to the given hostname. This is called DNS resolution and it is a blocking operation in Chronos.
For valid URIs, DNS resolution happens quickly enough to not interfere with the main logic. However, for invalid URIs (e.g. https://123.456.789.90) the resolution can stall for several seconds.
The main takeaway here is don't check invalid URIs.