@@ -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.
34373474fn 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