tests/testthat/test-utils-http.R

# Tests for HTTP utility helpers
library(testthat)
library(aisdk)

test_that("resolve_request_timeout_seconds uses default when unset", {
  withr::local_options(list(aisdk.http_timeout_seconds = NULL))
  withr::local_envvar(c(AISDK_HTTP_TIMEOUT_SECONDS = ""))

  expect_null(aisdk:::resolve_request_timeout_seconds())
})

test_that("resolve_request_timeout_seconds prefers explicit argument", {
  withr::local_options(list(aisdk.http_timeout_seconds = 300))
  withr::local_envvar(c(AISDK_HTTP_TIMEOUT_SECONDS = "600"))

  expect_equal(aisdk:::resolve_request_timeout_seconds(42), 42)
})

test_that("resolve_request_timeout_seconds falls back to option then env", {
  withr::local_options(list(aisdk.http_timeout_seconds = 300))
  withr::local_envvar(c(AISDK_HTTP_TIMEOUT_SECONDS = "600"))
  expect_equal(aisdk:::resolve_request_timeout_seconds(), 300)

  withr::local_options(list(aisdk.http_timeout_seconds = NULL))
  withr::local_envvar(c(AISDK_HTTP_TIMEOUT_SECONDS = "600"))
  expect_equal(aisdk:::resolve_request_timeout_seconds(), 600)
})

test_that("resolve_request_timeout_seconds rejects invalid values", {
  expect_error(
    aisdk:::resolve_request_timeout_seconds(0),
    "`timeout_seconds` must be a single positive number."
  )
  expect_error(
    aisdk:::resolve_request_timeout_seconds(-5),
    "`timeout_seconds` must be a single positive number."
  )
})

test_that("resolve_request_timeout_config uses different defaults for request and stream", {
  withr::local_options(list(
    aisdk.http_timeout_seconds = NULL,
    aisdk.http_total_timeout_seconds = NULL,
    aisdk.http_first_byte_timeout_seconds = NULL,
    aisdk.http_connect_timeout_seconds = NULL,
    aisdk.http_idle_timeout_seconds = NULL
  ))
  withr::local_envvar(c(
    AISDK_HTTP_TIMEOUT_SECONDS = "",
    AISDK_HTTP_TOTAL_TIMEOUT_SECONDS = "",
    AISDK_HTTP_FIRST_BYTE_TIMEOUT_SECONDS = "",
    AISDK_HTTP_CONNECT_TIMEOUT_SECONDS = "",
    AISDK_HTTP_IDLE_TIMEOUT_SECONDS = ""
  ))

  request_cfg <- aisdk:::resolve_request_timeout_config(request_type = "request")
  stream_cfg <- aisdk:::resolve_request_timeout_config(request_type = "stream")

  expect_null(request_cfg$total_timeout_seconds)
  expect_equal(request_cfg$first_byte_timeout_seconds, 300)
  expect_equal(request_cfg$connect_timeout_seconds, 10)
  expect_equal(request_cfg$idle_timeout_seconds, 120)
  expect_null(stream_cfg$total_timeout_seconds)
  expect_equal(stream_cfg$first_byte_timeout_seconds, 300)
  expect_equal(stream_cfg$connect_timeout_seconds, 10)
  expect_equal(stream_cfg$idle_timeout_seconds, 120)
})

test_that("resolve_request_timeout_config prefers explicit layered settings over defaults", {
  cfg <- aisdk:::resolve_request_timeout_config(
    timeout_seconds = 30,
    total_timeout_seconds = 90,
    first_byte_timeout_seconds = 45,
    connect_timeout_seconds = 5,
    idle_timeout_seconds = 15,
    request_type = "stream"
  )

  expect_equal(cfg$total_timeout_seconds, 90)
  expect_equal(cfg$first_byte_timeout_seconds, 45)
  expect_equal(cfg$connect_timeout_seconds, 5)
  expect_equal(cfg$idle_timeout_seconds, 15)
})

test_that("apply_request_timeout_config skips unsupported curl options", {
  req <- httr2::request("https://example.com")
  cfg <- list(
    total_timeout_seconds = NULL,
    first_byte_timeout_seconds = 45,
    connect_timeout_seconds = 5,
    idle_timeout_seconds = 15
  )

  req <- testthat::with_mocked_bindings(
    curl_option_available = function(option_name) FALSE,
    aisdk:::apply_request_timeout_config(req, cfg),
    .package = "aisdk"
  )

  expect_equal(req$options$connecttimeout, 5L)
  expect_null(req$options$server_response_timeout)
  expect_equal(req$options$low_speed_limit, 1L)
  expect_equal(req$options$low_speed_time, 15L)
})

test_that("prepare_json_post_request sets an explicit POST method", {
  req <- httr2::request("https://example.com")
  req <- aisdk:::prepare_json_post_request(req, list(message = "hi"))

  expect_equal(req$method, "POST")
  expect_equal(req$body$type, "json")
})

test_that("prepare_multipart_post_request sets an explicit POST method", {
  req <- httr2::request("https://example.com")
  req <- aisdk:::prepare_multipart_post_request(req, list(field = "value"))

  expect_equal(req$method, "POST")
  expect_equal(req$body$type, "multipart")
})

test_that("http_error_classes detects compatibility errors", {
  classes <- aisdk:::http_error_classes(
    status = 400,
    error_body = '{"error":{"message":"Unknown parameter: response_format","type":"invalid_request_error","param":"response_format","code":"unknown_parameter"}}'
  )

  expect_true("aisdk_api_compatibility_error" %in% classes)
  expect_true("aisdk_api_error" %in% classes)
})

test_that("http_error_classes detects timeout errors", {
  classes <- aisdk:::http_error_classes(
    status = 504,
    error_body = '{"error":{"message":"Request timed out"}}'
  )

  expect_true("aisdk_api_timeout_error" %in% classes)
})

test_that("request_error_classes separates timeout from generic network failures", {
  timeout_err <- simpleError("Connection timed out after 30 seconds")
  network_err <- simpleError("Could not resolve host")

  expect_true("aisdk_api_timeout_error" %in% aisdk:::request_error_classes(timeout_err))
  expect_true("aisdk_api_network_error" %in% aisdk:::request_error_classes(network_err))
})

test_that("normalize_base_urls accepts comma and newline separated URLs", {
  urls <- aisdk:::normalize_base_urls("https://one.test/v1, https://two.test/v1\nhttps://one.test/v1/")

  expect_equal(urls, c("https://one.test/v1", "https://two.test/v1"))
})

test_that("post_to_api fails over to the next configured endpoint", {
  rm(list = ls(envir = aisdk:::.aisdk_api_route_state),
     envir = aisdk:::.aisdk_api_route_state)
  withr::defer(
    rm(list = ls(envir = aisdk:::.aisdk_api_route_state),
       envir = aisdk:::.aisdk_api_route_state)
  )

  attempts <- character()

  testthat::local_mocked_bindings(
    should_skip_internet_check = function() TRUE,
    perform_request = function(req) {
      attempts <<- c(attempts, req$url)
      if (grepl("primary", req$url, fixed = TRUE)) {
        stop(simpleError("Could not resolve host"))
      }
      httr2::response(
        status_code = 200L,
        url = req$url,
        method = req$method %||% "POST",
        body = charToRaw('{"ok":true}')
      )
    },
    .package = "aisdk"
  )

  result <- suppressMessages(
    aisdk:::post_to_api(
      url = c("https://primary.test/v1/chat/completions", "https://backup.test/v1/chat/completions"),
      headers = list(),
      body = list(model = "mock"),
      max_retries = 0,
      initial_delay_ms = 0
    )
  )

  expect_true(isTRUE(result$ok))
  expect_equal(attempts, c("https://primary.test/v1/chat/completions", "https://backup.test/v1/chat/completions"))
})

test_that("post_to_api fails over on retryable HTTP statuses", {
  rm(list = ls(envir = aisdk:::.aisdk_api_route_state),
     envir = aisdk:::.aisdk_api_route_state)
  withr::defer(
    rm(list = ls(envir = aisdk:::.aisdk_api_route_state),
       envir = aisdk:::.aisdk_api_route_state)
  )

  attempts <- character()

  testthat::local_mocked_bindings(
    should_skip_internet_check = function() TRUE,
    perform_request = function(req) {
      attempts <<- c(attempts, req$url)
      if (grepl("primary", req$url, fixed = TRUE)) {
        return(httr2::response(
          status_code = 429L,
          url = req$url,
          method = req$method %||% "POST",
          headers = list(`retry-after-ms` = "0"),
          body = charToRaw('{"error":{"message":"rate limited"}}')
        ))
      }
      httr2::response(
        status_code = 200L,
        url = req$url,
        method = req$method %||% "POST",
        body = charToRaw('{"ok":true}')
      )
    },
    .package = "aisdk"
  )

  result <- suppressMessages(
    aisdk:::post_to_api(
      url = c("https://primary.test/v1/chat/completions", "https://backup.test/v1/chat/completions"),
      headers = list(),
      body = list(model = "mock"),
      max_retries = 0,
      initial_delay_ms = 0
    )
  )

  expect_true(isTRUE(result$ok))
  expect_equal(attempts, c("https://primary.test/v1/chat/completions", "https://backup.test/v1/chat/completions"))
})

test_that("failover preserves retryable HTTP error classes when all endpoints fail", {
  rm(list = ls(envir = aisdk:::.aisdk_api_route_state),
     envir = aisdk:::.aisdk_api_route_state)
  withr::defer(
    rm(list = ls(envir = aisdk:::.aisdk_api_route_state),
       envir = aisdk:::.aisdk_api_route_state)
  )

  testthat::local_mocked_bindings(
    should_skip_internet_check = function() TRUE,
    perform_request = function(req) {
      httr2::response(
        status_code = 429L,
        url = req$url,
        method = req$method %||% "POST",
        headers = list(`retry-after-ms` = "0"),
        body = charToRaw('{"error":{"message":"rate limited"}}')
      )
    },
    .package = "aisdk"
  )

  expect_error(
    suppressMessages(
      aisdk:::post_to_api(
        url = c("https://primary.test/v1/chat/completions", "https://backup.test/v1/chat/completions"),
        headers = list(),
        body = list(model = "mock"),
        max_retries = 0,
        initial_delay_ms = 0
      )
    ),
    class = "aisdk_api_rate_limit_error"
  )
})

test_that("failed routes cool down and are de-prioritized", {
  rm(list = ls(envir = aisdk:::.aisdk_api_route_state),
     envir = aisdk:::.aisdk_api_route_state)
  withr::defer(
    rm(list = ls(envir = aisdk:::.aisdk_api_route_state),
       envir = aisdk:::.aisdk_api_route_state)
  )

  primary <- "https://primary.test/v1/chat/completions"
  backup <- "https://backup.test/v1/chat/completions"

  aisdk:::mark_api_route_failure(primary, "network error")
  expect_equal(aisdk:::order_api_url_candidates(c(primary, backup)), c(backup, primary))

  aisdk:::mark_api_route_success(primary)
  expect_equal(aisdk:::order_api_url_candidates(c(primary, backup)), c(primary, backup))
})

test_that("stream_from_api retries connection failures before any event is delivered", {
  attempts <- 0L
  chunks <- list()
  done_seen <- FALSE

  testthat::local_mocked_bindings(
    should_skip_internet_check = function() TRUE,
    stream_perform_connection = function(req) {
      attempts <<- attempts + 1L
      if (attempts == 1L) {
        stop(simpleError("Failed to perform HTTP request. Caused by error in `open.connection()`: cannot open the connection"))
      }
      resp <- new.env(parent = emptyenv())
      resp$events <- list(
        list(data = "{\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}"),
        list(data = "[DONE]")
      )
      resp
    },
    stream_response_status = function(resp) 200L,
    stream_response_is_complete = function(resp) FALSE,
    stream_response_sse = function(resp) {
      event <- resp$events[[1]]
      resp$events <- resp$events[-1]
      event
    },
    .package = "aisdk"
  )

  suppressMessages(
    aisdk:::stream_from_api(
      url = "https://example.test/chat/completions",
      headers = list(),
      body = list(model = "mock", stream = TRUE),
      callback = function(data, done) {
        if (isTRUE(done)) {
          done_seen <<- TRUE
        } else {
          chunks <<- c(chunks, list(data))
        }
      },
      initial_delay_ms = 0
    )
  )

  expect_equal(attempts, 2L)
  expect_length(chunks, 1)
  expect_equal(chunks[[1]]$choices[[1]]$delta$content, "ok")
  expect_true(done_seen)
})

test_that("stream_from_api fails over before the first event", {
  rm(list = ls(envir = aisdk:::.aisdk_api_route_state),
     envir = aisdk:::.aisdk_api_route_state)
  withr::defer(
    rm(list = ls(envir = aisdk:::.aisdk_api_route_state),
       envir = aisdk:::.aisdk_api_route_state)
  )

  attempts <- character()
  chunks <- list()
  done_seen <- FALSE

  testthat::local_mocked_bindings(
    should_skip_internet_check = function() TRUE,
    stream_perform_connection = function(req) {
      attempts <<- c(attempts, req$url)
      if (grepl("primary", req$url, fixed = TRUE)) {
        stop(simpleError("cannot open the connection"))
      }
      resp <- new.env(parent = emptyenv())
      resp$events <- list(
        list(data = "{\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}"),
        list(data = "[DONE]")
      )
      resp
    },
    stream_response_status = function(resp) 200L,
    stream_response_is_complete = function(resp) FALSE,
    stream_response_sse = function(resp) {
      event <- resp$events[[1]]
      resp$events <- resp$events[-1]
      event
    },
    .package = "aisdk"
  )

  suppressMessages(
    aisdk:::stream_from_api(
      url = c("https://primary.test/v1/chat/completions", "https://backup.test/v1/chat/completions"),
      headers = list(),
      body = list(model = "mock", stream = TRUE),
      callback = function(data, done) {
        if (isTRUE(done)) {
          done_seen <<- TRUE
        } else {
          chunks <<- c(chunks, list(data))
        }
      },
      max_retries = 0,
      initial_delay_ms = 0
    )
  )

  expect_equal(attempts, c("https://primary.test/v1/chat/completions", "https://backup.test/v1/chat/completions"))
  expect_length(chunks, 1)
  expect_true(done_seen)
})

test_that("stream_from_api defaults to five retries before any event is delivered", {
  attempts <- 0L

  testthat::local_mocked_bindings(
    should_skip_internet_check = function() TRUE,
    stream_perform_connection = function(req) {
      attempts <<- attempts + 1L
      if (attempts <= 5L) {
        stop(simpleError("Failed to perform HTTP request. Caused by error in `open.connection()`: cannot open the connection"))
      }
      resp <- new.env(parent = emptyenv())
      resp$events <- list(list(data = "[DONE]"))
      resp
    },
    stream_response_status = function(resp) 200L,
    stream_response_is_complete = function(resp) FALSE,
    stream_response_sse = function(resp) {
      event <- resp$events[[1]]
      resp$events <- resp$events[-1]
      event
    },
    .package = "aisdk"
  )

  suppressMessages(
    aisdk:::stream_from_api(
      url = "https://example.test/chat/completions",
      headers = list(),
      body = list(model = "mock", stream = TRUE),
      callback = function(data, done) NULL,
      initial_delay_ms = 0
    )
  )

  expect_equal(attempts, 6L)
})

test_that("stream_from_api does not retry after a stream event is delivered", {
  attempts <- 0L
  chunks <- list()

  testthat::local_mocked_bindings(
    should_skip_internet_check = function() TRUE,
    stream_perform_connection = function(req) {
      attempts <<- attempts + 1L
      resp <- new.env(parent = emptyenv())
      resp$events <- list(
        list(data = "{\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}"),
        simpleError("Connection reset by peer")
      )
      resp
    },
    stream_response_status = function(resp) 200L,
    stream_response_is_complete = function(resp) FALSE,
    stream_response_sse = function(resp) {
      event <- resp$events[[1]]
      resp$events <- resp$events[-1]
      if (inherits(event, "error")) {
        stop(event)
      }
      event
    },
    .package = "aisdk"
  )

  expect_error(
    aisdk:::stream_from_api(
      url = "https://example.test/chat/completions",
      headers = list(),
      body = list(model = "mock", stream = TRUE),
      callback = function(data, done) {
        if (!isTRUE(done)) {
          chunks <<- c(chunks, list(data))
        }
      },
      initial_delay_ms = 0
    ),
    class = "aisdk_stream_partial_error"
  )

  expect_equal(attempts, 1L)
  expect_length(chunks, 1)
  expect_equal(chunks[[1]]$choices[[1]]$delta$content, "partial")
})

# --- Issue 2: preflight is non-fatal by default ---------------------------

test_that("preflight_internet returns TRUE when has_internet() is TRUE", {
  testthat::local_mocked_bindings(
    has_internet = function() TRUE, .package = "curl"
  )
  withr::with_envvar(c(AISDK_SKIP_INTERNET_CHECK = NA, AISDK_PREFLIGHT_MODE = NA), {
    expect_true(aisdk:::preflight_internet("https://example.test"))
  })
})

test_that("preflight_internet warns once but returns TRUE when has_internet=FALSE (default mode)", {
  testthat::local_mocked_bindings(
    has_internet = function() FALSE, .package = "curl"
  )
  rm(list = ls(envir = aisdk:::.aisdk_preflight_warned),
     envir = aisdk:::.aisdk_preflight_warned)
  withr::with_envvar(c(AISDK_SKIP_INTERNET_CHECK = NA, AISDK_PREFLIGHT_MODE = NA), {
    expect_message(
      result <- aisdk:::preflight_internet("https://uncrawlable.test/v1/x"),
      "attempting request anyway"
    )
    expect_true(result)
    expect_silent(aisdk:::preflight_internet("https://uncrawlable.test/v1/x"))
  })
})

test_that("preflight_internet returns FALSE when AISDK_PREFLIGHT_MODE=abort and offline", {
  testthat::local_mocked_bindings(
    has_internet = function() FALSE, .package = "curl"
  )
  withr::with_envvar(
    c(AISDK_SKIP_INTERNET_CHECK = NA, AISDK_PREFLIGHT_MODE = "abort"),
    {
      expect_message(result <- aisdk:::preflight_internet("https://offline.test"),
                     "Internet connection is not available")
      expect_false(result)
    }
  )
})

test_that("preflight_internet honors AISDK_SKIP_INTERNET_CHECK=1", {
  testthat::local_mocked_bindings(
    has_internet = function() FALSE, .package = "curl"
  )
  withr::with_envvar(c(AISDK_SKIP_INTERNET_CHECK = "1"), {
    expect_silent(result <- aisdk:::preflight_internet("https://skipped.test"))
    expect_true(result)
  })
})

Try the aisdk package in your browser

Any scripts or data that you put into this service are public.

aisdk documentation built on May 29, 2026, 9:07 a.m.