Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Sending Alerts with POST Requests

Goal: Learn how to send POST HTTP requests and set request headers.

Source code: chapter6/src/uptimemon.nim

How cool would it be to get notified about a service being down to your phone? This way, you can launch the program and just go on with your business and not constantly monitor the terminal window.

ntfy is a service that allows to send push notifications with POST requests. Let's use it to send notifications when our program detects a [NOK] or [ERR].

Set Up ntfy

  1. Go to ntfy.sh/app.
  2. Click on Subscribe to topic in the sidebar, click GENERATE NAME in the popup, copy the generated name, and SUBSCRIBE. We'll use this unique topic name to send the notifications to.
  3. Click on GRANT NOW to allow push notifications from your browser.
  4. Keep the browser open.

Add Alerts

Here's the version of the program with alerting capabilities:

import std/sequtils
import chronos/apps/http/httpclient

const
  ntfyTopic = "<YOUR_NTFY_TOPIC_NAME>"
  uris = @[
    "https://duckduckgo.com/?q=chronos", "https://mock.codes/403",
    "http://10.255.255.1", "https://html.spec.whatwg.org/",
    "https://mock.codes/200",
  ]

proc sendAlert(
    session: HttpSessionRef, message: string, priority = 3
) {.async: (raises: [CancelledError]).} =
  let
    headers = {"Title": "Chronos Uptime Monitor", "Priority": $priority}
    body = message.stringToBytes()
    request = HttpClientRequestRef.new(
      session,
      "https://ntfy.sh/" & ntfyTopic,
      meth = MethodPost,
      headers = headers,
      body = body,
    ).valueOr:
      echo "[WRN] Failed to send alert: " & error
      return

  try:
    let response = await request.send().wait(5.seconds)
    await response.closeWait()
  except HttpError, FuturePendingError, AsyncTimeoutError:
    echo "[WRN] Failed to send alert: " & getCurrentExceptionMsg()
  finally:
    await request.closeWait()

proc findMarker(
    bodyReader: HttpBodyReader
): Future[bool] {.async: (raises: [AsyncStreamError, CancelledError]).} =
  const
    marker = "<html"
    readLimit = 10 * 1024

  var
    totalRead = 0
    sample = newString(len(marker) - 1)
    found = false

  proc findMarkerInSample(data: openArray[byte]): (int, bool) =
    if len(data) == 0:
      (0, false)
    else:
      sample = sample[^(len(marker) - 1) .. high(sample)]
      sample &= bytesToString(data)
      found = marker in sample
      totalRead += len(data)
      (len(data), found and totalRead <= readLimit)

  await bodyReader.readMessage(findMarkerInSample)
  found

proc check(session: HttpSessionRef, uri: string) {.async: (raises: [CancelledError]).} =
  let
    request = HttpClientRequestRef.new(session, uri).valueOr:
      echo "[ERR] " & uri & ": " & error
      return
    response =
      try:
        await request.send().wait(5.seconds)
      except HttpError, AsyncTimeoutError:
        echo "[ERR] " & uri & ": " & getCurrentExceptionMsg()
      finally:
        await request.closeWait()

  try:
    if response.status == 200:
      let
        bodyReader = response.getBodyReader()
        markerFound =
          try:
            await bodyReader.findMarker()
          finally:
            await bodyReader.closeWait()

      if markerFound:
        echo "[OK] " & uri
      else:
        let message = "[NOK] " & uri & ": Not valid HTML"
        echo message
        await session.sendAlert(message)
    else:
      let message = "[NOK] " & uri & ": " & $response.status
      echo message
      await session.sendAlert(message)
  except HttpError, AsyncStreamError:
    let message = "[ERR] " & uri & ": " & getCurrentExceptionMsg()
    echo message
    await session.sendAlert(message, 4)
  finally:
    await response.closeWait()

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)

As usual, let's examine the changes part by part.

const
  ntfyTopic = "<YOUR_NTFY_TOPIC_NAME>"

Define a new constant for the ntfy topic name you copied earlier. Replace YOUR_NTFY_TOPIC_NAME with the actual value you copied from ntfy.

proc sendAlert(
    session: HttpSessionRef, message: string, priority = 3
) {.async: (raises: [CancelledError]).} =
  let

Define a new async function that will do the request sending to ntfy. We'll send those requests in the same session so we pass it to the function as session.

message is the text we want to send in the notification.

priority is a number that defines the style of the notification in ntfy. ntfy recognizes five priority levels from 1 to 5: the higher the number, the "scarier" the message.

headers = {"Title": "Chronos Uptime Monitor", "Priority": $priority}

ntfy uses headers to customize notifications, e.g. Title and Priority.

Here we set the headers as an arrays of tuples using Nim's shortcut syntax.

body = message.stringToBytes()

Requests body must be a sequence of bytes so we convert our text message using stringToBytes.

request = HttpClientRequestRef.new(
  session,
  "https://ntfy.sh/" & ntfyTopic,
  meth = MethodPost,
  headers = headers,
  body = body,
).valueOr:
  echo "[WRN] Failed to send alert: " & error
  return

Create the request with the necessary properties. meth is the request's HTTP method.

try:
  let response = await request.send().wait(5.seconds)
  await response.closeWait()
except HttpError, FuturePendingError, AsyncTimeoutError:
  echo "[WRN] Failed to send alert: " & getCurrentExceptionMsg()
finally:
  await request.closeWait()

If the request was successfully created (request.isOk), we try to send it with send() and discard it (with closeWait).

If the request couldn't be sent (e.g. ntfy is unavailable), we print a warning.

proc check(session: HttpSessionRef, uri: string) {.async: (raises: [CancelledError]).} =
  let
    request = HttpClientRequestRef.new(session, uri).valueOr:
      echo "[ERR] " & uri & ": " & error
      return
    response =
      try:
        await request.send().wait(5.seconds)
      except HttpError, AsyncTimeoutError:
        echo "[ERR] " & uri & ": " & getCurrentExceptionMsg()
      finally:
        await request.closeWait()

  try:
    if response.status == 200:
      let
        bodyReader = response.getBodyReader()
        markerFound =
          try:
            await bodyReader.findMarker()
          finally:
            await bodyReader.closeWait()

      if markerFound:
        echo "[OK] " & uri
      else:
        let message = "[NOK] " & uri & ": Not valid HTML"
        echo message
        await session.sendAlert(message)
    else:
      let message = "[NOK] " & uri & ": " & $response.status
      echo message
      await session.sendAlert(message)
  except HttpError, AsyncStreamError:
    let message = "[ERR] " & uri & ": " & getCurrentExceptionMsg()
    echo message
    await session.sendAlert(message, 4)
  finally:
    await response.closeWait()

Finally, we add calls to sendAlert in the check branches for [NOK] and [ERR]. Run the code and observe alerts appearing in your browser accompanied by push notifications:

ntfy alerts in browser

To receive the notifications on your phone, install ntfy mobile app and subscribe to the same topic.

In the final chapter, we'll see how to scale our application and add some finishing touches!