fix(manifest): actionable errors when USCOGDATA_URL is unset or returns non-JSON
R-CMD-check / check (push) Successful in 2m5s
R-CMD-check / check (push) Successful in 2m5s
`cog_gov_search()` (and every other verb) used to fail with a cryptic `jsonlite` lexical error when the package's placeholder default URL was hit and the server returned an HTML welcome page that got cached as `manifest.json`. Three guards added: 1. `.check_url_configured()` aborts with class `uscogdata_url_not_configured` when the resolved URL is empty or still contains the `REPLACE_WITH_SHARE_TOKEN` sentinel. Message names both `Sys.setenv(USCOGDATA_URL = ...)` and `options(uscogdata.url = ...)` remediations and points at the bundled fixture. 2. `.fetch_or_cache_manifest()` parses the response body before persisting it. Non-JSON payloads raise class `uscogdata_invalid_manifest` (URL, Content-Type, parse error) and never touch the on-disk cache. 3. Cache writes are atomic via a sibling tempfile + `file.rename`, and existing caches with non-JSON content are silently refetched instead of returning a parse error to the caller. Local-path manifests that aren't valid JSON now surface the same `uscogdata_invalid_manifest` class with file context.
This commit is contained in:
+101
-9
@@ -1,5 +1,66 @@
|
||||
# R/manifest.R
|
||||
|
||||
# Sentinel substring baked into the placeholder default URL. If we see this
|
||||
# in the resolved URL, the user hasn't configured USCOGDATA_URL yet.
|
||||
.PLACEHOLDER_TOKEN <- "REPLACE_WITH_SHARE_TOKEN"
|
||||
|
||||
#' Abort with actionable guidance when the resolved corpus URL is still the
|
||||
#' placeholder shipped with the package (or any URL containing the sentinel).
|
||||
#' Called from `cog_open()` before any I/O so users see a clear message
|
||||
#' instead of a downstream JSON parse error.
|
||||
#' @noRd
|
||||
.check_url_configured <- function(url) {
|
||||
if (!is.character(url) || length(url) != 1L || !nzchar(url)) {
|
||||
cli::cli_abort(c(
|
||||
"USCOGDATA_URL is not configured.",
|
||||
i = "Set the corpus location via one of:",
|
||||
"*" = "{.code Sys.setenv(USCOGDATA_URL = \"<url-or-local-path>/\")}",
|
||||
"*" = "{.code options(uscogdata.url = \"<url-or-local-path>/\")}",
|
||||
i = "For an offline smoke test, use the bundled fixture: {.code system.file(\"extdata/fixture_corpus\", package = \"uscogdata\")}."
|
||||
), class = "uscogdata_url_not_configured")
|
||||
}
|
||||
if (grepl(.PLACEHOLDER_TOKEN, url, fixed = TRUE)) {
|
||||
sentinel <- .PLACEHOLDER_TOKEN
|
||||
cli::cli_abort(c(
|
||||
"USCOGDATA_URL is not configured (placeholder URL detected).",
|
||||
x = "Current value contains the sentinel {.val {sentinel}}: {.url {url}}",
|
||||
i = "Set the corpus location via one of:",
|
||||
"*" = "{.code Sys.setenv(USCOGDATA_URL = \"<url-or-local-path>/\")}",
|
||||
"*" = "{.code options(uscogdata.url = \"<url-or-local-path>/\")}",
|
||||
i = "For an offline smoke test, use the bundled fixture: {.code system.file(\"extdata/fixture_corpus\", package = \"uscogdata\")}.",
|
||||
i = "For the live Civilytics corpus, request the Nextcloud share URL from the package maintainer."
|
||||
), class = "uscogdata_url_not_configured")
|
||||
}
|
||||
invisible(url)
|
||||
}
|
||||
|
||||
#' Try to parse a JSON file. Returns parsed object on success, NULL on
|
||||
#' any parse failure (so callers can decide whether to refetch).
|
||||
#' @noRd
|
||||
.try_parse_manifest_file <- function(path) {
|
||||
tryCatch(
|
||||
jsonlite::fromJSON(path, simplifyVector = FALSE),
|
||||
error = function(e) NULL
|
||||
)
|
||||
}
|
||||
|
||||
#' Abort with a clear, classified error when a manifest payload (string or
|
||||
#' file) cannot be parsed as JSON. Surfaces the URL, content-type if known,
|
||||
#' and the underlying parse error.
|
||||
#' @noRd
|
||||
.abort_invalid_manifest <- function(source, content_type = NA_character_, parse_error = NULL) {
|
||||
ct <- if (is.na(content_type) || !nzchar(content_type)) "<unknown>" else content_type
|
||||
pmsg <- if (is.null(parse_error)) "" else conditionMessage(parse_error)
|
||||
cli::cli_abort(c(
|
||||
"Corpus manifest is not valid JSON.",
|
||||
x = "Source: {source}",
|
||||
i = "Content-Type: {ct}",
|
||||
i = "Likely causes: USCOGDATA_URL points at a login page, a 404 HTML page, or the wrong share; or the corpus has not been published yet.",
|
||||
i = "Set USCOGDATA_URL to a directory (local path or HTTPS) that serves manifest.json directly.",
|
||||
if (nzchar(pmsg)) c(">" = "Parse error: {pmsg}") else NULL
|
||||
), class = "uscogdata_invalid_manifest")
|
||||
}
|
||||
|
||||
#' Fetch manifest.json from URL (or read from a local fixture path),
|
||||
#' cache locally, validate TTL.
|
||||
#' @noRd
|
||||
@@ -11,23 +72,54 @@
|
||||
if (!file.exists(local_manifest)) {
|
||||
cli::cli_abort("Local fixture has no manifest.json at {local_manifest}")
|
||||
}
|
||||
return(jsonlite::fromJSON(local_manifest, simplifyVector = FALSE))
|
||||
return(tryCatch(
|
||||
jsonlite::fromJSON(local_manifest, simplifyVector = FALSE),
|
||||
error = function(e) .abort_invalid_manifest(source = local_manifest, parse_error = e)
|
||||
))
|
||||
}
|
||||
|
||||
cache_path <- file.path(cache_dir, "manifest.json")
|
||||
ttl <- as.integer(.cfg("manifest_ttl_secs"))
|
||||
|
||||
needs_fetch <- !file.exists(cache_path) ||
|
||||
difftime(Sys.time(), file.info(cache_path)$mtime, units = "secs") > ttl
|
||||
cache_fresh <- file.exists(cache_path) &&
|
||||
difftime(Sys.time(), file.info(cache_path)$mtime, units = "secs") <= ttl
|
||||
|
||||
if (needs_fetch) {
|
||||
resp <- httr2::request(paste0(url, "manifest.json")) |>
|
||||
httr2::req_error(is_error = function(r) httr2::resp_status(r) >= 400) |>
|
||||
httr2::req_perform()
|
||||
writeLines(httr2::resp_body_string(resp), cache_path)
|
||||
# Honor a fresh cache only if its contents still parse as JSON. A previous
|
||||
# version of this package could write HTML directly into the cache; treat
|
||||
# such poisoned caches as if they were missing so the next call recovers.
|
||||
if (cache_fresh) {
|
||||
parsed <- .try_parse_manifest_file(cache_path)
|
||||
if (!is.null(parsed)) return(parsed)
|
||||
}
|
||||
|
||||
jsonlite::fromJSON(cache_path, simplifyVector = FALSE)
|
||||
resp <- httr2::request(paste0(url, "manifest.json")) |>
|
||||
httr2::req_error(is_error = function(r) httr2::resp_status(r) >= 400) |>
|
||||
httr2::req_perform()
|
||||
body <- httr2::resp_body_string(resp)
|
||||
|
||||
# Parse BEFORE persisting. If the server returned HTML / a login page /
|
||||
# any non-JSON body with a 2xx status, we must not write it to the cache.
|
||||
parsed <- tryCatch(
|
||||
jsonlite::fromJSON(body, simplifyVector = FALSE),
|
||||
error = function(e) {
|
||||
ct <- tryCatch(httr2::resp_content_type(resp), error = function(e2) NA_character_)
|
||||
.abort_invalid_manifest(
|
||||
source = paste0(url, "manifest.json"),
|
||||
content_type = ct,
|
||||
parse_error = e
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
# Atomic write: tmp file alongside cache_path (same filesystem -> no EXDEV)
|
||||
# then rename. Ensures a partial write or interrupted process never
|
||||
# replaces a previously-good cache.
|
||||
if (!dir.exists(cache_dir)) dir.create(cache_dir, recursive = TRUE)
|
||||
tmp <- paste0(cache_path, ".tmp.", Sys.getpid())
|
||||
on.exit(if (file.exists(tmp)) unlink(tmp), add = TRUE)
|
||||
writeLines(body, tmp)
|
||||
file.rename(tmp, cache_path)
|
||||
parsed
|
||||
}
|
||||
|
||||
#' @noRd
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#' @noRd
|
||||
cog_open <- function(url = .resolve_url(),
|
||||
cache_dir = .resolve_cache_dir()) {
|
||||
.check_url_configured(url)
|
||||
if (!dir.exists(cache_dir)) dir.create(cache_dir, recursive = TRUE)
|
||||
|
||||
con <- DBI::dbConnect(duckdb::duckdb())
|
||||
|
||||
Reference in New Issue
Block a user