| 169 |
169 |
|
/// Create a ureq agent with redirects disabled to prevent SSRF via
|
| 170 |
170 |
|
/// redirect to localhost/private IPs.
|
| 171 |
171 |
|
fn make_agent() -> ureq::Agent {
|
| 172 |
|
- |
ureq::AgentBuilder::new()
|
| 173 |
|
- |
.timeout(HTTP_TIMEOUT)
|
| 174 |
|
- |
.redirects(0)
|
| 175 |
|
- |
.user_agent(USER_AGENT)
|
| 176 |
|
- |
.build()
|
|
172 |
+ |
ureq::Agent::new_with_config(
|
|
173 |
+ |
ureq::Agent::config_builder()
|
|
174 |
+ |
.timeout_global(Some(HTTP_TIMEOUT))
|
|
175 |
+ |
.max_redirects(0)
|
|
176 |
+ |
// With max_redirects(0), ureq 3 defaults to failing a 3xx with
|
|
177 |
+ |
// Error::TooManyRedirects. Hand the 3xx back as a plain response
|
|
178 |
+ |
// instead, which is what ureq 2's redirects(0) did, so a redirect
|
|
179 |
+ |
// stays a visible response rather than a transport error.
|
|
180 |
+ |
.max_redirects_will_error(false)
|
|
181 |
+ |
// 4xx/5xx come back as responses so format_status_error can read
|
|
182 |
+ |
// both the body and the headers; ureq 3's Error::StatusCode
|
|
183 |
+ |
// carries neither.
|
|
184 |
+ |
.http_status_as_error(false)
|
|
185 |
+ |
.user_agent(USER_AGENT)
|
|
186 |
+ |
.build(),
|
|
187 |
+ |
)
|
| 177 |
188 |
|
}
|
| 178 |
189 |
|
|
| 179 |
|
- |
/// Classify a `ureq::Error` into a `BB_ERR:`-prefixed string that carries
|
| 180 |
|
- |
/// error category information through the Rhai string-error channel.
|
|
190 |
+ |
/// Default backoff advertised to plugins when a 429 carries no usable
|
|
191 |
+ |
/// `Retry-After`.
|
|
192 |
+ |
const DEFAULT_RETRY_AFTER_SECS: u64 = 60;
|
|
193 |
+ |
|
|
194 |
+ |
/// Classify a transport-level `ureq::Error` into a `BB_ERR:`-prefixed string
|
|
195 |
+ |
/// that carries error category information through the Rhai string-error
|
|
196 |
+ |
/// channel. Status codes never reach here: the agent turns off
|
|
197 |
+ |
/// `http_status_as_error`, so they are handled by `format_status_error`.
|
| 181 |
198 |
|
fn format_ureq_error(err: ureq::Error) -> String {
|
| 182 |
|
- |
match err {
|
| 183 |
|
- |
ureq::Error::Status(status, response) => {
|
| 184 |
|
- |
// Cap error body read to prevent OOM on malicious large error responses
|
| 185 |
|
- |
let mut bytes = Vec::new();
|
| 186 |
|
- |
let _ = response
|
| 187 |
|
- |
.into_reader()
|
| 188 |
|
- |
.take(MAX_RESPONSE_BYTES)
|
| 189 |
|
- |
.read_to_end(&mut bytes);
|
| 190 |
|
- |
let body = String::from_utf8_lossy(&bytes);
|
| 191 |
|
- |
let body_preview = if body.len() > 200 {
|
| 192 |
|
- |
&body[..200]
|
| 193 |
|
- |
} else {
|
| 194 |
|
- |
&body
|
| 195 |
|
- |
};
|
| 196 |
|
- |
match status {
|
| 197 |
|
- |
401 | 403 => format!("{BB_ERR_PREFIX}auth:HTTP {status}: {body_preview}"),
|
| 198 |
|
- |
429 => {
|
| 199 |
|
- |
// Try to read Retry-After header (already consumed, use default)
|
| 200 |
|
- |
// ureq consumes the response, so we default to 60s
|
| 201 |
|
- |
let retry_secs = 60;
|
| 202 |
|
- |
format!("{BB_ERR_PREFIX}rate_limited:{retry_secs}:HTTP 429: {body_preview}")
|
| 203 |
|
- |
}
|
| 204 |
|
- |
404 | 410 => format!("{BB_ERR_PREFIX}config:HTTP {status}: {body_preview}"),
|
| 205 |
|
- |
500..=599 => format!("{BB_ERR_PREFIX}transient:HTTP {status}: {body_preview}"),
|
| 206 |
|
- |
_ => format!("{BB_ERR_PREFIX}transient:HTTP {status}: {body_preview}"),
|
| 207 |
|
- |
}
|
| 208 |
|
- |
}
|
| 209 |
|
- |
ureq::Error::Transport(ref transport) => {
|
| 210 |
|
- |
let msg = transport.to_string();
|
| 211 |
|
- |
format!("{BB_ERR_PREFIX}transient:Transport error: {msg}")
|
| 212 |
|
- |
}
|
|
199 |
+ |
format!("{BB_ERR_PREFIX}transient:Transport error: {err}")
|
|
200 |
+ |
}
|
|
201 |
+ |
|
|
202 |
+ |
/// Classify a failing response into a `BB_ERR:`-prefixed string, or `None` if
|
|
203 |
+ |
/// the response is not a failure.
|
|
204 |
+ |
///
|
|
205 |
+ |
/// The 400 cutoff mirrors ureq 2, which only raised `Error::Status` at 400 and
|
|
206 |
+ |
/// above. A 3xx therefore stays a normal response, as it was under
|
|
207 |
+ |
/// `redirects(0)`.
|
|
208 |
+ |
fn format_status_error(resp: &mut ureq::http::Response<ureq::Body>) -> Option<String> {
|
|
209 |
+ |
let status = resp.status().as_u16();
|
|
210 |
+ |
if status < 400 {
|
|
211 |
+ |
return None;
|
| 213 |
212 |
|
}
|
|
213 |
+ |
|
|
214 |
+ |
let retry_after = parse_retry_after(&resp.headers().get("retry-after").cloned());
|
|
215 |
+ |
|
|
216 |
+ |
// Cap error body read to prevent OOM on malicious large error responses
|
|
217 |
+ |
let mut bytes = Vec::new();
|
|
218 |
+ |
let _ = resp
|
|
219 |
+ |
.body_mut()
|
|
220 |
+ |
.as_reader()
|
|
221 |
+ |
.take(MAX_RESPONSE_BYTES)
|
|
222 |
+ |
.read_to_end(&mut bytes);
|
|
223 |
+ |
let body = String::from_utf8_lossy(&bytes);
|
|
224 |
+ |
let body_preview = if body.len() > 200 {
|
|
225 |
+ |
&body[..200]
|
|
226 |
+ |
} else {
|
|
227 |
+ |
&body
|
|
228 |
+ |
};
|
|
229 |
+ |
|
|
230 |
+ |
Some(match status {
|
|
231 |
+ |
401 | 403 => format!("{BB_ERR_PREFIX}auth:HTTP {status}: {body_preview}"),
|
|
232 |
+ |
429 => {
|
|
233 |
+ |
let retry_secs = retry_after.unwrap_or(DEFAULT_RETRY_AFTER_SECS);
|
|
234 |
+ |
format!("{BB_ERR_PREFIX}rate_limited:{retry_secs}:HTTP 429: {body_preview}")
|
|
235 |
+ |
}
|
|
236 |
+ |
404 | 410 => format!("{BB_ERR_PREFIX}config:HTTP {status}: {body_preview}"),
|
|
237 |
+ |
_ => format!("{BB_ERR_PREFIX}transient:HTTP {status}: {body_preview}"),
|
|
238 |
+ |
})
|
|
239 |
+ |
}
|
|
240 |
+ |
|
|
241 |
+ |
/// Read a `Retry-After` delay in seconds.
|
|
242 |
+ |
///
|
|
243 |
+ |
/// Only the delta-seconds form is honoured. The HTTP-date form is rare in
|
|
244 |
+ |
/// practice and a bad clock would turn it into a wildly wrong backoff, so an
|
|
245 |
+ |
/// unparseable value falls back to the default rather than being guessed at.
|
|
246 |
+ |
fn parse_retry_after(header: &Option<ureq::http::HeaderValue>) -> Option<u64> {
|
|
247 |
+ |
let secs: u64 = header.as_ref()?.to_str().ok()?.trim().parse().ok()?;
|
|
248 |
+ |
// Clamp to a day so a hostile server cannot park a plugin indefinitely.
|
|
249 |
+ |
Some(secs.min(24 * 60 * 60))
|
| 214 |
250 |
|
}
|
| 215 |
251 |
|
|
| 216 |
252 |
|
/// Register host functions available to Rhai scripts.
|
| 255 |
291 |
|
check_request_limit(&counter)?;
|
| 256 |
292 |
|
check_fetch_deadline(&deadline)?;
|
| 257 |
293 |
|
|
| 258 |
|
- |
let response = agent_clone.get(url).call().map_err(format_ureq_error)?;
|
|
294 |
+ |
let mut response = agent_clone.get(url).call().map_err(format_ureq_error)?;
|
|
295 |
+ |
if let Some(e) = format_status_error(&mut response) {
|
|
296 |
+ |
return Err(e.into());
|
|
297 |
+ |
}
|
| 259 |
298 |
|
|
| 260 |
299 |
|
let mut bytes = Vec::new();
|
| 261 |
300 |
|
response
|
|
301 |
+ |
.into_body()
|
| 262 |
302 |
|
.into_reader()
|
| 263 |
303 |
|
.take(MAX_RESPONSE_BYTES)
|
| 264 |
304 |
|
.read_to_end(&mut bytes)
|
| 277 |
317 |
|
check_request_limit(&counter)?;
|
| 278 |
318 |
|
check_fetch_deadline(&deadline)?;
|
| 279 |
319 |
|
|
| 280 |
|
- |
let response = agent.get(url).call().map_err(format_ureq_error)?;
|
|
320 |
+ |
let mut response = agent.get(url).call().map_err(format_ureq_error)?;
|
|
321 |
+ |
if let Some(e) = format_status_error(&mut response) {
|
|
322 |
+ |
return Err(e.into());
|
|
323 |
+ |
}
|
| 281 |
324 |
|
|
| 282 |
325 |
|
let mut body = Vec::new();
|
| 283 |
326 |
|
response
|
|
327 |
+ |
.into_body()
|
| 284 |
328 |
|
.into_reader()
|
| 285 |
329 |
|
.take(MAX_RESPONSE_BYTES)
|
| 286 |
330 |
|
.read_to_end(&mut body)
|
| 487 |
531 |
|
|
| 488 |
532 |
|
use super::{
|
| 489 |
533 |
|
BB_ERR_PREFIX, MAX_REQUESTS_PER_FETCH, check_fetch_deadline, check_request_limit,
|
| 490 |
|
- |
validate_url,
|
|
534 |
+ |
format_status_error, validate_url,
|
| 491 |
535 |
|
};
|
| 492 |
536 |
|
|
| 493 |
537 |
|
/// Truncate text with ellipsis (mirrors the Rhai-registered closure for testing).
|
| 905 |
949 |
|
let err = result.unwrap_err().to_string();
|
| 906 |
950 |
|
assert!(err.contains("BB_ERR:rate_limited:120:"), "got: {err}");
|
| 907 |
951 |
|
}
|
|
952 |
+ |
|
|
953 |
+ |
// ── HTTP status classification ──────────────────────────────
|
|
954 |
+ |
|
|
955 |
+ |
fn response(
|
|
956 |
+ |
status: u16,
|
|
957 |
+ |
headers: &[(&str, &str)],
|
|
958 |
+ |
body: &str,
|
|
959 |
+ |
) -> ureq::http::Response<ureq::Body> {
|
|
960 |
+ |
let mut builder = ureq::http::Response::builder().status(status);
|
|
961 |
+ |
for (k, v) in headers {
|
|
962 |
+ |
builder = builder.header(*k, *v);
|
|
963 |
+ |
}
|
|
964 |
+ |
builder
|
|
965 |
+ |
.body(ureq::Body::builder().data(body))
|
|
966 |
+ |
.expect("build response")
|
|
967 |
+ |
}
|
|
968 |
+ |
|
|
969 |
+ |
#[test]
|
|
970 |
+ |
fn success_and_redirect_statuses_are_not_errors() {
|
|
971 |
+ |
assert!(format_status_error(&mut response(200, &[], "ok")).is_none());
|
|
972 |
+ |
assert!(format_status_error(&mut response(204, &[], "")).is_none());
|
|
973 |
+ |
// redirects(0) under ureq 2 handed 3xx back as a plain response, and
|
|
974 |
+ |
// max_redirects(0) plus max_redirects_will_error(false) keeps that.
|
|
975 |
+ |
assert!(format_status_error(&mut response(301, &[], "")).is_none());
|
|
976 |
+ |
}
|
|
977 |
+ |
|
|
978 |
+ |
#[test]
|
|
979 |
+ |
fn status_maps_to_the_expected_category() {
|
|
980 |
+ |
let auth = format_status_error(&mut response(401, &[], "nope")).unwrap();
|
|
981 |
+ |
assert!(auth.starts_with("BB_ERR:auth:HTTP 401:"), "got: {auth}");
|
|
982 |
+ |
|
|
983 |
+ |
let config = format_status_error(&mut response(404, &[], "gone")).unwrap();
|
|
984 |
+ |
assert!(
|
|
985 |
+ |
config.starts_with("BB_ERR:config:HTTP 404:"),
|
|
986 |
+ |
"got: {config}"
|
|
987 |
+ |
);
|
|
988 |
+ |
|
|
989 |
+ |
let transient = format_status_error(&mut response(503, &[], "later")).unwrap();
|
|
990 |
+ |
assert!(
|
|
991 |
+ |
transient.starts_with("BB_ERR:transient:HTTP 503:"),
|
|
992 |
+ |
"got: {transient}"
|
|
993 |
+ |
);
|
|
994 |
+ |
}
|
|
995 |
+ |
|
|
996 |
+ |
#[test]
|
|
997 |
+ |
fn body_is_included_in_the_error_and_capped_at_a_preview() {
|
|
998 |
+ |
let err = format_status_error(&mut response(500, &[], "boom")).unwrap();
|
|
999 |
+ |
assert!(err.ends_with("boom"), "got: {err}");
|
|
1000 |
+ |
|
|
1001 |
+ |
let long = "x".repeat(500);
|
|
1002 |
+ |
let err = format_status_error(&mut response(500, &[], &long)).unwrap();
|
|
1003 |
+ |
assert!(err.ends_with(&"x".repeat(200)), "preview not capped");
|
|
1004 |
+ |
assert!(!err.contains(&"x".repeat(201)), "preview not capped");
|
|
1005 |
+ |
}
|
|
1006 |
+ |
|
|
1007 |
+ |
/// ureq 2 could not reach the 429 response headers, so the retry delay was
|
|
1008 |
+ |
/// hardcoded to 60s. ureq 3 keeps the response, so the real header wins.
|
|
1009 |
+ |
#[test]
|
|
1010 |
+ |
fn rate_limit_uses_the_retry_after_header() {
|
|
1011 |
+ |
let err =
|
|
1012 |
+ |
format_status_error(&mut response(429, &[("retry-after", "120")], "slow")).unwrap();
|
|
1013 |
+ |
assert!(err.starts_with("BB_ERR:rate_limited:120:"), "got: {err}");
|
|
1014 |
+ |
}
|
|
1015 |
+ |
|
|
1016 |
+ |
#[test]
|
|
1017 |
+ |
fn rate_limit_falls_back_when_retry_after_is_absent_or_unusable() {
|
|
1018 |
+ |
for headers in [
|
|
1019 |
+ |
&[][..],
|
|
1020 |
+ |
&[("retry-after", "Wed, 21 Oct 2026 07:28:00 GMT")][..],
|
|
1021 |
+ |
&[("retry-after", "not-a-number")][..],
|
|
1022 |
+ |
] {
|
|
1023 |
+ |
let err = format_status_error(&mut response(429, headers, "slow")).unwrap();
|
|
1024 |
+ |
assert!(
|
|
1025 |
+ |
err.starts_with("BB_ERR:rate_limited:60:"),
|
|
1026 |
+ |
"got: {err} for {headers:?}"
|
|
1027 |
+ |
);
|
|
1028 |
+ |
}
|
|
1029 |
+ |
}
|
|
1030 |
+ |
|
|
1031 |
+ |
#[test]
|
|
1032 |
+ |
fn rate_limit_retry_after_is_clamped_to_a_day() {
|
|
1033 |
+ |
let err =
|
|
1034 |
+ |
format_status_error(&mut response(429, &[("retry-after", "999999999")], "")).unwrap();
|
|
1035 |
+ |
assert!(err.starts_with("BB_ERR:rate_limited:86400:"), "got: {err}");
|
|
1036 |
+ |
}
|
| 908 |
1037 |
|
}
|