source_api_apiFaults.bs

import "pkg:/source/utils/misc.bs"

' Requests an RTA spec makes fail, answer slowly or answer empty on purpose, so a spec can
' drive a screen's failure, slow-server and nothing-there paths against a server that is
' healthy, fast and full. Pure: the API coordinator holds the list and calls these from its
' own thread, and the only caller is compiled in under ENABLE_RTA (ApiQueueTask).
'
' A spec writes rules to m.global.rtaFailRequests:
'   { prefix: "itemQuery_usersItems", kind: "timeout" | "http" | "slow" | "empty", status: 500, ms: 20000, times: 1, after: 0 }
' A rule matches a request whose requestId starts with `prefix`. `times` is how many
' matching requests it applies to; without it, every one. `after` is how many matching
' requests it lets through first, for a request id that repeats too quickly for a spec to set
' the rule in between (a grid's page 1 and page 2).
'
' A failed request is answered with the response the pool itself delivers for that failure. A
' slow one is sent, and its real answer is held until `ms` after it was sent, with its slot kept
' busy all that time: what a slow server costs the app, pool slot included. An empty one is
' answered as a server with nothing to give answers: ok, with an empty list in the shape its
' request expects. Nothing marks any of them as injected: code that could tell them apart
' could handle a test differently from the real thing, which is what these specs exist to
' catch.
namespace apiFaults

  ' The rules a spec wrote, checked. A rule that cannot be honored exactly is dropped rather
  ' than guessed at, so a typo in a spec fails that spec's assertions instead of failing some
  ' other request.
  '
  ' @param rules - the value of m.global.rtaFailRequests
  ' @return an array of { prefix, kind, status, ms, remaining, passes }, remaining -1 meaning no limit
  function fromRules(rules as dynamic) as object
    faults = []
    if type(rules) <> "roArray" then return faults

    for each rule in rules
      fault = checkedRule(rule)
      if isValid(fault) then faults.push(fault)
    end for
    return faults
  end function

  ' The first fault that applies to `requestId`, counted as used; invalid when none does.
  ' Mutates `faults`: the caller keeps the list across requests.
  '
  ' A rule still letting requests through (`after`) claims each match and passes it, rather
  ' than handing it to a later rule: the spec asked for those requests to succeed.
  function take(faults as object, requestId as string) as dynamic
    for each fault in faults
      if fault.remaining <> 0 and requestId.StartsWith(fault.prefix)
        if fault.passes > 0
          fault.passes = fault.passes - 1
          return invalid
        end if
        if fault.remaining > 0 then fault.remaining = fault.remaining - 1
        return fault
      end if
    end for
    return invalid
  end function

  ' Whether `fault` delays a request rather than failing it.
  function isSlow(fault as object) as boolean
    return fault.kind = "slow"
  end function

  ' How long the coordinator may wait for its next event before a held answer is due: 0 (no
  ' limit, as wait() reads it) when nothing is held, and never less than 1 ms otherwise, since
  ' 0 would mean waiting forever past the due time.
  '
  ' @param held - the held answers, each with `dueAt` in ms on the coordinator's clock
  ' @param nowMs - the coordinator's clock now
  function nextWaitMs(held as object, nowMs as integer) as integer
    if held.count() = 0 then return 0
    earliest = held[0].dueAt
    for each answer in held
      if answer.dueAt < earliest then earliest = answer.dueAt
    end for
    waitMs = earliest - nowMs
    if waitMs < 1 then return 1
    return waitMs
  end function

  ' The held answers that are due, taken out of `held` in the order they were held. Mutates
  ' `held`: the caller keeps the list across events.
  '
  ' @param held - the held answers, each with `dueAt`
  ' @param nowMs - the coordinator's clock now
  function takeDue(held as object, nowMs as integer) as object
    due = []
    i = 0
    while i < held.count()
      if held[i].dueAt <= nowMs
        due.push(held[i])
        held.delete(i)
      else
        i++
      end if
    end while
    return due
  end function

  ' The pool's response for a request answered this way, the shape ApiTask.executeRequest
  ' builds. A timeout is what roku-requests returns when it gives up: not ok, and no status or
  ' body, because it only reads those from a response that arrived. An empty answer is ok with
  ' nothing in it: a bare `[]` for a list request (ApiClient.listReq), which ApiTask's shape
  ' check would otherwise demand, and an empty query result for any other.
  '
  ' @param expectsList - the request declared a bare-array body (apiResponse.expectsList)
  function responseFor(fault as object, requestId as string, expectsList = false as boolean) as object
    if fault.kind = "empty"
      body = { Items: [], TotalRecordCount: 0 }
      if expectsList then body = []
      return { requestId: requestId, ok: true, statusCode: 200, json: body, text: FormatJson(body) }
    end if
    if fault.kind = "http"
      return { requestId: requestId, ok: false, statusCode: fault.status, json: invalid, text: "" }
    end if
    return { requestId: requestId, ok: false, statusCode: invalid, json: invalid, text: invalid }
  end function

  function checkedRule(rule as dynamic) as dynamic
    if type(rule) <> "roAssociativeArray" then return invalid

    prefix = rule.prefix
    if not isString(prefix) or prefix = "" then return invalid

    status = 0
    ms = 0
    if rule.kind = "http"
      status = wholeNumber(rule.status)
      ' roku-requests counts 1-399 as ok, so anything below 400 would not be a failure
      if status < 400 then return invalid
    else if rule.kind = "slow"
      ms = wholeNumber(rule.ms)
      if ms < 1 then return invalid
    else if rule.kind <> "timeout" and rule.kind <> "empty"
      return invalid
    end if

    remaining = -1
    if rule.doesExist("times")
      remaining = wholeNumber(rule.times)
      if remaining < 1 then return invalid
    end if

    passes = 0
    if rule.doesExist("after")
      passes = wholeNumber(rule.after)
      if passes < 0 then return invalid
    end if

    return { prefix: prefix, kind: rule.kind, status: status, ms: ms, remaining: remaining, passes: passes }
  end function

  function isString(value as dynamic) as boolean
    valueType = type(value)
    return valueType = "String" or valueType = "roString"
  end function

  ' A number arrives with whatever numeric type the ODC write produced. -1 for anything else.
  function wholeNumber(value as dynamic) as integer
    if GetInterface(value, "ifInt") <> invalid then return value
    if GetInterface(value, "ifLongInt") <> invalid then return value
    if GetInterface(value, "ifFloat") <> invalid or GetInterface(value, "ifDouble") <> invalid then return Int(value)
    return -1
  end function

end namespace