Skip to content

Commit 934548c

Browse files
committed
fix: relay HEAD via GET range probe
1 parent 8c59627 commit 934548c

1 file changed

Lines changed: 176 additions & 16 deletions

File tree

src/domain_fronter.rs

Lines changed: 176 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,7 +1106,7 @@ impl DomainFronter {
11061106
}
11071107
}
11081108

1109-
/// Keep the Apps Script container warm with a periodic HEAD ping.
1109+
/// Keep the Apps Script container warm with a periodic lightweight GET ping.
11101110
///
11111111
/// The TCP/TLS pool stays warm via `run_pool_refill`, but the V8
11121112
/// container Apps Script runs in goes cold ~5min after the last
@@ -1122,9 +1122,8 @@ impl DomainFronter {
11221122
/// Bypasses the response cache (`cache_key_opt = None`) and the
11231123
/// inflight coalescer — otherwise the second iteration would just
11241124
/// hit the cached response from the first and never reach Apps
1125-
/// Script. The relay payload itself is the cheapest non-error one
1126-
/// we can build: a HEAD against `http://example.com/` returns a few
1127-
/// hundred bytes, no body decode, no auth.
1125+
/// Script. Apps Script UrlFetchApp does not accept `head` as a method, so
1126+
/// the relay payload uses a GET range probe instead of HEAD.
11281127
///
11291128
/// Best-effort. Failures are debug-logged so a flaky network or
11301129
/// quota-exhausted account doesn't spam warnings every 4 minutes.
@@ -1139,8 +1138,9 @@ impl DomainFronter {
11391138
// for the debug line. We intentionally don't use relay()
11401139
// here because that path goes through the cache + coalesce
11411140
// layer, which would short-circuit subsequent pings.
1141+
let headers = [("Range".to_string(), "bytes=0-0".to_string())];
11421142
let _ = self
1143-
.relay_uncoalesced("HEAD", "http://example.com/", &[], &[], None)
1143+
.relay_uncoalesced("GET", "http://example.com/", &headers, &[], None)
11441144
.await;
11451145
tracing::debug!(
11461146
"container keepalive: {}ms",
@@ -1755,6 +1755,10 @@ impl DomainFronter {
17551755
url
17561756
};
17571757

1758+
if method.eq_ignore_ascii_case("HEAD") {
1759+
return self.relay_head_via_get_probe(url, headers).await;
1760+
}
1761+
17581762
// Exit-node short-circuit: route through the configured second-hop
17591763
// relay (Deno Deploy / fly.io / etc.) for hosts that need a
17601764
// non-Google exit IP. The cache + coalesce layer below is bypassed
@@ -1866,6 +1870,39 @@ impl DomainFronter {
18661870
bytes
18671871
}
18681872

1873+
async fn relay_head_via_get_probe(&self, url: &str, headers: &[(String, String)]) -> Vec<u8> {
1874+
let probe_headers = synthetic_head_probe_headers(headers);
1875+
let t_start = Instant::now();
1876+
1877+
let raw = if self.exit_node_matches(url) {
1878+
match self.relay_via_exit_node("GET", url, &probe_headers, &[]).await {
1879+
Ok(bytes) => bytes,
1880+
Err(e) if !e.is_retryable() => {
1881+
self.relay_failures.fetch_add(1, Ordering::Relaxed);
1882+
let inner = e.into_inner();
1883+
self.record_site(url, false, 0, t_start.elapsed().as_nanos() as u64);
1884+
return error_response(502, &format!("Relay error: {}", inner));
1885+
}
1886+
Err(e) => {
1887+
tracing::warn!(
1888+
"exit node failed for synthetic HEAD probe {}: {} — falling back to direct Apps Script",
1889+
url,
1890+
e
1891+
);
1892+
self.relay_uncoalesced("GET", url, &probe_headers, &[], None)
1893+
.await
1894+
}
1895+
}
1896+
} else {
1897+
self.relay_uncoalesced("GET", url, &probe_headers, &[], None)
1898+
.await
1899+
};
1900+
1901+
let bytes = synthesize_head_response_from_get_probe(&raw);
1902+
self.record_site(url, false, bytes.len() as u64, t_start.elapsed().as_nanos() as u64);
1903+
bytes
1904+
}
1905+
18691906
/// Range-parallel relay — the big difference between this port and
18701907
/// the upstream Python version. Apps Script's per-call cost is
18711908
/// ~flat (1-2s regardless of payload), so a 10MB single GET is
@@ -3435,17 +3472,7 @@ fn assemble_full_200(src_headers: &[(String, String)], body: &[u8]) -> Vec<u8> {
34353472
/// `assemble_full_200`'s header-skip rules so the two paths produce
34363473
/// identical headers for a given probe.
34373474
fn assemble_200_head(src_headers: &[(String, String)], declared_length: u64) -> Vec<u8> {
3438-
let skip = |k: &str| {
3439-
matches!(
3440-
k.to_ascii_lowercase().as_str(),
3441-
"content-length"
3442-
| "content-range"
3443-
| "content-encoding"
3444-
| "transfer-encoding"
3445-
| "connection"
3446-
| "keep-alive",
3447-
)
3448-
};
3475+
let skip = |k: &str| skip_synthetic_response_header(k);
34493476
let mut out: Vec<u8> = b"HTTP/1.1 200 OK\r\n".to_vec();
34503477
for (k, v) in src_headers {
34513478
if skip(k) {
@@ -3460,6 +3487,77 @@ fn assemble_200_head(src_headers: &[(String, String)], declared_length: u64) ->
34603487
out
34613488
}
34623489

3490+
fn synthetic_head_probe_headers(headers: &[(String, String)]) -> Vec<(String, String)> {
3491+
let mut out: Vec<(String, String)> = headers
3492+
.iter()
3493+
.filter(|(k, _)| {
3494+
!k.eq_ignore_ascii_case("range")
3495+
&& !k.eq_ignore_ascii_case("content-length")
3496+
&& !k.eq_ignore_ascii_case("content-type")
3497+
})
3498+
.cloned()
3499+
.collect();
3500+
out.push(("Range".to_string(), "bytes=0-0".to_string()));
3501+
out
3502+
}
3503+
3504+
fn synthesize_head_response_from_get_probe(raw: &[u8]) -> Vec<u8> {
3505+
let Some((status, headers, body)) = split_response(raw) else {
3506+
return raw.to_vec();
3507+
};
3508+
3509+
let (head_status, declared_length) = if status == 206 {
3510+
let len = match parse_content_range(&headers) {
3511+
Some(range) if range.start == 0 => range.total,
3512+
_ => response_content_length(&headers).unwrap_or(body.len() as u64),
3513+
};
3514+
(200, len)
3515+
} else {
3516+
(status, response_content_length(&headers).unwrap_or(body.len() as u64))
3517+
};
3518+
3519+
assemble_response_head(head_status, &headers, declared_length)
3520+
}
3521+
3522+
fn response_content_length(headers: &[(String, String)]) -> Option<u64> {
3523+
headers
3524+
.iter()
3525+
.find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
3526+
.and_then(|(_, v)| v.trim().parse::<u64>().ok())
3527+
}
3528+
3529+
fn assemble_response_head(
3530+
status: u16,
3531+
src_headers: &[(String, String)],
3532+
declared_length: u64,
3533+
) -> Vec<u8> {
3534+
let mut out: Vec<u8> =
3535+
format!("HTTP/1.1 {} {}\r\n", status, status_reason(status)).into_bytes();
3536+
for (k, v) in src_headers {
3537+
if skip_synthetic_response_header(k) {
3538+
continue;
3539+
}
3540+
out.extend_from_slice(k.as_bytes());
3541+
out.extend_from_slice(b": ");
3542+
out.extend_from_slice(v.as_bytes());
3543+
out.extend_from_slice(b"\r\n");
3544+
}
3545+
out.extend_from_slice(format!("Content-Length: {}\r\n\r\n", declared_length).as_bytes());
3546+
out
3547+
}
3548+
3549+
fn skip_synthetic_response_header(k: &str) -> bool {
3550+
matches!(
3551+
k.to_ascii_lowercase().as_str(),
3552+
"content-length"
3553+
| "content-range"
3554+
| "content-encoding"
3555+
| "transfer-encoding"
3556+
| "connection"
3557+
| "keep-alive",
3558+
)
3559+
}
3560+
34633561
/// Apply `transform_head` to the head block of an HTTP/1.x response
34643562
/// (everything up to and including the first `\r\n\r\n` terminator),
34653563
/// then write the transformed head followed by the unchanged body to
@@ -5581,6 +5679,68 @@ hello";
55815679
assert_eq!(&full[..idx + sep.len()], head_only.as_slice());
55825680
}
55835681

5682+
#[test]
5683+
fn synthetic_head_from_range_probe_uses_total_length_and_no_body() {
5684+
let mut raw = b"HTTP/1.1 206 Partial Content\r\n\
5685+
Content-Type: application/octet-stream\r\n\
5686+
Content-Disposition: attachment; filename=test.AppImage\r\n\
5687+
Content-Range: bytes 0-0/790340426\r\n\
5688+
Content-Length: 1\r\n\
5689+
Connection: keep-alive\r\n\r\n"
5690+
.to_vec();
5691+
raw.extend_from_slice(b"x");
5692+
5693+
let got = synthesize_head_response_from_get_probe(&raw);
5694+
let got_s = String::from_utf8_lossy(&got);
5695+
5696+
assert!(got_s.starts_with("HTTP/1.1 200 OK\r\n"), "got: {}", got_s);
5697+
assert!(got_s.contains("Content-Type: application/octet-stream\r\n"));
5698+
assert!(got_s.contains("Content-Disposition: attachment; filename=test.AppImage\r\n"));
5699+
assert!(got_s.contains("Content-Length: 790340426\r\n"));
5700+
assert!(!got_s.contains("Content-Range:"));
5701+
assert!(!got_s.contains("Connection:"));
5702+
assert!(
5703+
got_s.ends_with("\r\n\r\n"),
5704+
"HEAD response must not include a body: {}",
5705+
got_s
5706+
);
5707+
}
5708+
5709+
#[test]
5710+
fn synthetic_head_from_unknown_total_range_still_hides_probe_status() {
5711+
let raw = b"HTTP/1.1 206 Partial Content\r\n\
5712+
Content-Range: bytes 0-0/*\r\n\
5713+
Content-Length: 0\r\n\r\n";
5714+
5715+
let got = synthesize_head_response_from_get_probe(raw);
5716+
let got_s = String::from_utf8_lossy(&got);
5717+
5718+
assert!(got_s.starts_with("HTTP/1.1 200 OK\r\n"), "got: {}", got_s);
5719+
assert!(got_s.contains("Content-Length: 0\r\n"));
5720+
assert!(!got_s.contains("Content-Range:"));
5721+
assert!(got_s.ends_with("\r\n\r\n"));
5722+
}
5723+
5724+
#[test]
5725+
fn synthetic_head_probe_headers_replace_client_range() {
5726+
let input = vec![
5727+
("Accept".to_string(), "*/*".to_string()),
5728+
("Range".to_string(), "bytes=10-20".to_string()),
5729+
("User-Agent".to_string(), "curl/8".to_string()),
5730+
];
5731+
5732+
let got = synthetic_head_probe_headers(&input);
5733+
5734+
assert_eq!(
5735+
got,
5736+
vec![
5737+
("Accept".to_string(), "*/*".to_string()),
5738+
("User-Agent".to_string(), "curl/8".to_string()),
5739+
("Range".to_string(), "bytes=0-0".to_string()),
5740+
]
5741+
);
5742+
}
5743+
55845744
#[tokio::test]
55855745
async fn write_response_with_head_transform_applies_to_head_not_body() {
55865746
// The bridge between writer-based API and the buffered/error

0 commit comments

Comments
 (0)