Tutorial: Converting synchronous HTTP requests to event driven code

This tutorial will explain how to convert a synchronous program into an event driven program.

The starting program makes two HTTP requests and then compares the output. It does this in a synchronous fashion, sending one request, waiting for the response, sending the second request, waiting for the response and then it can process the data.

These type of synchronous requests will prevent the program from doing other processing in the event loop. Within a Tk program, this will lock up the GUI.

If the HTTP requests are large or the responding server slow, you may want to send out the HTTP requests simultaneously and wait for both responses to save time.

References:

The starting program (synchronous):

#!/usr/bin/tclsh

# tested 2017-8
package require http

proc fetchURL { url } {
  set htoken {}
  set htoken [http::geturl $url]
  set ncode [::http::ncode $htoken]
  set data [::http::data $htoken]
  ::http::cleanup $htoken
  return [list $ncode $data]
}

proc processData { } {
  lassign [fetchURL http://ballroomdj.org/versioncheck.txt] ncodeA dataA
  lassign [fetchURL http://ballroomdj.org/versioncheck.html] ncodeB dataB
  if { $ncodeA != 200 || $ncodeB != 200 } {
    puts "HTTP error return"
  } else {
    set verA [string trim $dataA]
    regexp {<p>([\d.]+)</p>} $dataB junk verB
    if { $verA eq $verB } {
      puts "version match ($verA)"
    } else {
      puts "version mismatch $verA/$verB"
    }
  }
}

proc main { } {
  processData
}
main

The first step is to change the ::http::geturl command to use its -command callback.

A global array is introduced to store the return values from the HTTP requests and to store other data items.

A tag value (A and B in this example) is passed to the fetchURL procedure to indicate under what name the data should be stored.

This tag is passed on to the ::http::geturl -command callback process.

As this tutorial uses a Tcl program, the event loop must be explicitly entered, and a vwait forever is added at the end of the program.

(Note: the after idle trick in the ::http::geturl -command callback prevents the http module from swallowing errors (it's a design bug in the http module). This will save you a lot of trouble debugging the program.)

# this is a code fragment
variable vars

proc httpProcess { tag htoken } {
  variable vars

  set ncode [::http::ncode $htoken]
  set data [::http::data $htoken]
  ::http::cleanup $htoken
  set vars(http.data.$tag) [list $ncode $data]
}

proc fetchURL { tag url } {
  set htoken {}
  set htoken [http::geturl $url -command \
      [list after idle [list ::httpProcess $tag $callback]]]
}

...

proc processData { } {
  fetchURL A http://ballroomdj.org/versioncheck.txt
  fetchURL B http://ballroomdj.org/versioncheck.html
  ...
}

...

main
vwait forever

Now the program is broken. The HTTP return data is probably getting saved, but how does it get processed, and when?

It is tempting to say "I want to process this data when I get a good return value". But then the program will hang when an HTTP error occurs.

The trace facility from Tcl is used to track variable access. The HTTP callback will update a variable indicating completion. The HTTP callback must update upon any response, good or bad.

Two new values are added: one to say how many HTTP responses are expected and one to track how many HTTP requests have been processed. A trace command is added to track the HTTP completion counter and a new procedure is added to handle the completion.

# this is a code fragment
proc httpProcess { tag htoken } {
  ...
  incr vars(http.return.count)
}

...

proc processCheck { } {
  variable vars

  if { $vars(http.return.count) >= $vars(http.return.expect) } {
    trace remove variable vars(http.return.count) write ::processCheck
    # do something
  }
}

proc processData { } {
  variable vars

  set vars(http.return.count) 0
  set vars(http.return.expect) 2
  trace add variable vars(http.return.count) write ::processCheck

  fetchURL A http://ballroomdj.org/versioncheck.txt
  fetchURL B http://ballroomdj.org/versioncheck.html
}

In this example, there are two types of requests, and corresponding processes that need to be executed upon receipt of the data. Let's generalize that processing and configure it to happen when the HTTP request is received.

To do this, an additional callback routine is passed to the fetchUrl procedure. This callback routine is passed on to the httpProcess procedure which will call it upon receipt of the data. The httpProcess procedure will also pass the tag value on to the processing procedure so it knows which data set to work with.

# this is a code fragment
proc httpProcess { tag callback htoken } {
  ...
  # the callback may be a list with additional arguments.
  # the {*} operator will split it apart into words.
  {*}$callback $tag
  incr vars(http.return.count)
}

proc fetchURL { tag callback url } {
  set htoken {}
  set htoken [http::geturl $url -command \
      [list after idle [list ::httpProcess $tag $callback]]]
}

proc processText { tag } {
  variable vars

  lassign $vars(http.data.$tag) ncode data
  set vars(ncode.$tag) $ncode
  set vars(data.$tag) {}
  if { $ncode == 200 } {
    set vars(data.$tag) [string trim $data]
  }
}

proc processHTML { tag } {
  variable vars

  lassign $vars(http.data.$tag) ncode data
  set vars(ncode.$tag) $ncode
  set vars(data.$tag) {}
  if { $ncode == 200 } {
    regexp {<p>([\d.]+)</p>} $data junk vars(data.$tag)
  }
}

...

proc processData { } {
  fetchURL A ::processText http://ballroomdj.org/versioncheck.txt
  fetchURL B ::processHTML http://ballroomdj.org/versioncheck.html
}

Now the final step is to actually do something when the HTTP requests are finished. Let's keep the processCheck procedure generic so that it can be used for other purposes.

# this is a code fragment

proc processFinal { } {
  variable vars

  if { $vars(ncode.A) != 200 || $vars(ncode.B) != 200 } {
    puts "HTTP error return"
  } else {
    if { $vars(data.A) eq $vars(data.B) } {
      puts "version match ($vars(data.A))"
    } else {
      puts "version mismatch $vars(data.A)/$vars(data.B)"
    }
  }
  exit
}

proc processCheck { args } {
  variable vars

  if { $vars(http.return.count) >= $vars(http.return.expect) } {
    trace remove variable vars(http.return.count) write ::processCheck
    {*}$vars(finalproc)
  }
}

proc processData { } {
  ...
  set vars(finalproc) ::processFinal
  ...
}

Now the program is finished. The procedures have been generalized for reuse. Our processing is contained within the processData and processFinal procedures.

The program is now event driven and other concurrent processing will proceed normally.

The final program (event driven):

#!/usr/bin/tclsh

# tested 2017-8

package require http
variable vars

proc httpProcess { tag callback htoken } {
  variable vars

  set ncode [::http::ncode $htoken]
  set data [::http::data $htoken]
  ::http::cleanup $htoken
  set vars(http.data.$tag) [list $ncode $data]
  {*}$callback $tag
  incr vars(http.return.count)
}

proc fetchURL { tag callback url } {
  set htoken {}
  set htoken [http::geturl $url -command \
      [list after idle [list ::httpProcess $tag $callback]]]
}

proc processText { tag } {
  variable vars

  lassign $vars(http.data.$tag) ncode data
  set vars(ncode.$tag) $ncode
  set vars(data.$tag) {}
  if { $ncode == 200 } {
    set vars(data.$tag) [string trim $data]
  }
}

proc processHTML { tag } {
  variable vars

  lassign $vars(http.data.$tag) ncode data
  set vars(ncode.$tag) $ncode
  set vars(data.$tag) {}
  if { $ncode == 200 } {
    regexp {<p>([\d.]+)</p>} $data junk vars(data.$tag)
  }
}

proc processFinal { } {
  variable vars

  if { $vars(ncode.A) != 200 || $vars(ncode.B) != 200 } {
    puts "HTTP error return"
  } else {
    if { $vars(data.A) eq $vars(data.B) } {
      puts "version match ($vars(data.A))"
    } else {
      puts "version mismatch $vars(data.A)/$vars(data.B)"
    }
  }
  exit
}

proc processCheck { args } {
  variable vars

  if { $vars(http.return.count) >= $vars(http.return.expect) } {
    trace remove variable vars(http.return.count) write ::processCheck
    {*}$vars(finalproc)
  }
}

proc processData { } {
  variable vars

  set vars(http.return.count) 0
  set vars(http.return.expect) 2
  set vars(finalproc) ::processFinal
  trace add variable vars(http.return.count) write ::processCheck

  fetchURL A ::processText http://ballroomdj.org/versioncheck.txt
  fetchURL B ::processHTML http://ballroomdj.org/versioncheck.html
}

proc main { } {
  processData
}
main
vwait ::forever