Nothing
# 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)
})
})
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.