@@ -509,7 +509,6 @@ async fn submit_block_impl(
509509 remove_v2_support : bool ,
510510 force_404s : bool ,
511511) -> Result < Response > {
512- // Setup test environment
513512 setup_test_env ( ) ;
514513 let signer = random_secret ( ) ;
515514 let pubkey = signer. public_key ( ) ;
@@ -574,3 +573,152 @@ async fn submit_block_impl(
574573 assert_eq ! ( res. status( ) , expected_code) ;
575574 Ok ( res)
576575}
576+
577+ // Retry-as-JSON trigger must be restricted
578+ // to 406 Not Acceptable and 415 Unsupported Media Type. Any other 4xx is
579+ // orthogonal to encoding and MUST surface unchanged.
580+
581+ /// Shared fixture: relay returns `ssz_status` when the PBS sends SSZ,
582+ /// everything else takes the happy path. Returns `(Response, attempt_count)`.
583+ /// `api_version` picks v1 or v2 endpoint; `relay_types` controls what the
584+ /// relay advertises as supported so the happy JSON path works when retried.
585+ async fn submit_block_ssz_override (
586+ api_version : BuilderApiVersion ,
587+ ssz_status : StatusCode ,
588+ ) -> Result < ( Response , u64 ) > {
589+ setup_test_env ( ) ;
590+ let signer = random_secret ( ) ;
591+ let pubkey = signer. public_key ( ) ;
592+ let chain = Chain :: Holesky ;
593+ let pbs_listener = get_free_listener ( ) . await ;
594+ let relay_listener = get_free_listener ( ) . await ;
595+ let pbs_port = pbs_listener. local_addr ( ) . unwrap ( ) . port ( ) ;
596+ let relay_port = relay_listener. local_addr ( ) . unwrap ( ) . port ( ) ;
597+
598+ let mock_relay = generate_mock_relay ( relay_port, pubkey) ?;
599+ let mut mock_relay_state = MockRelayState :: new ( chain, signer) ;
600+ // Relay only advertises JSON so the retry (which goes out as JSON) lands
601+ // on a clean success path. The SSZ-status override below intercepts
602+ // before the supported-types check, so the first SSZ attempt still hits
603+ // our injected status regardless of what's advertised here.
604+ mock_relay_state. supported_content_types = Arc :: new ( HashSet :: from ( [ EncodingType :: Json ] ) ) ;
605+ mock_relay_state = mock_relay_state. with_submit_block_ssz_status ( ssz_status) ;
606+ let mock_state = Arc :: new ( mock_relay_state) ;
607+ tokio:: spawn ( start_mock_relay_service_with_listener ( mock_state. clone ( ) , relay_listener) ) ;
608+
609+ let pbs_config = get_pbs_config ( pbs_port) ;
610+ let config = to_pbs_config ( chain, pbs_config, vec ! [ mock_relay] ) ;
611+ let state = PbsState :: new ( config, PathBuf :: new ( ) ) ;
612+ drop ( pbs_listener) ;
613+ tokio:: spawn ( PbsService :: run :: < ( ) , DefaultBuilderApi > ( state) ) ;
614+
615+ tokio:: time:: sleep ( Duration :: from_millis ( 100 ) ) . await ;
616+
617+ let signed_blinded_block = load_test_signed_blinded_block ( ) ;
618+ let mock_validator = MockValidator :: new ( pbs_port) ?;
619+ // The BN sends SSZ; PBS forwards SSZ first, that's what our override hits.
620+ let accept_types = HashSet :: from ( [ EncodingType :: Ssz , EncodingType :: Json ] ) ;
621+ let res = match api_version {
622+ BuilderApiVersion :: V1 => {
623+ mock_validator
624+ . do_submit_block_v1 (
625+ Some ( signed_blinded_block) ,
626+ accept_types,
627+ EncodingType :: Ssz ,
628+ ForkName :: Electra ,
629+ )
630+ . await ?
631+ }
632+ BuilderApiVersion :: V2 => {
633+ mock_validator
634+ . do_submit_block_v2 (
635+ Some ( signed_blinded_block) ,
636+ accept_types,
637+ EncodingType :: Ssz ,
638+ ForkName :: Electra ,
639+ )
640+ . await ?
641+ }
642+ } ;
643+ Ok ( ( res, mock_state. received_submit_block ( ) ) )
644+ }
645+
646+ /// 406 is the spec-defined "retry with a different media type" signal, so we
647+ /// MUST retry as JSON and succeed.
648+ #[ tokio:: test]
649+ async fn test_submit_block_ssz_retries_as_json_on_406 ( ) -> Result < ( ) > {
650+ let ( res, attempts) =
651+ submit_block_ssz_override ( BuilderApiVersion :: V1 , StatusCode :: NOT_ACCEPTABLE ) . await ?;
652+ assert_eq ! ( res. status( ) , StatusCode :: OK , "retry-as-JSON must succeed on 406" ) ;
653+ assert_eq ! ( attempts, 2 , "expected SSZ attempt + JSON retry" ) ;
654+ Ok ( ( ) )
655+ }
656+
657+ /// 415 is the other spec-defined media-type rejection status; same retry.
658+ #[ tokio:: test]
659+ async fn test_submit_block_ssz_retries_as_json_on_415 ( ) -> Result < ( ) > {
660+ let ( res, attempts) =
661+ submit_block_ssz_override ( BuilderApiVersion :: V1 , StatusCode :: UNSUPPORTED_MEDIA_TYPE )
662+ . await ?;
663+ assert_eq ! ( res. status( ) , StatusCode :: OK , "retry-as-JSON must succeed on 415" ) ;
664+ assert_eq ! ( attempts, 2 ) ;
665+ Ok ( ( ) )
666+ }
667+
668+ /// 400 Bad Request is a validation failure — encoding is not the problem.
669+ /// Retrying doubles relay load and hides the real error. MUST NOT retry.
670+ #[ tokio:: test]
671+ async fn test_submit_block_ssz_does_not_retry_on_400 ( ) -> Result < ( ) > {
672+ let ( _res, attempts) =
673+ submit_block_ssz_override ( BuilderApiVersion :: V1 , StatusCode :: BAD_REQUEST ) . await ?;
674+ assert_eq ! ( attempts, 1 , "400 is not a media-type error; must not retry" ) ;
675+ Ok ( ( ) )
676+ }
677+
678+ /// 401 Unauthorized — auth problem, not encoding. No retry.
679+ #[ tokio:: test]
680+ async fn test_submit_block_ssz_does_not_retry_on_401 ( ) -> Result < ( ) > {
681+ let ( _res, attempts) =
682+ submit_block_ssz_override ( BuilderApiVersion :: V1 , StatusCode :: UNAUTHORIZED ) . await ?;
683+ assert_eq ! ( attempts, 1 ) ;
684+ Ok ( ( ) )
685+ }
686+
687+ /// 409 Conflict — state mismatch. No retry.
688+ #[ tokio:: test]
689+ async fn test_submit_block_ssz_does_not_retry_on_409 ( ) -> Result < ( ) > {
690+ let ( _res, attempts) =
691+ submit_block_ssz_override ( BuilderApiVersion :: V1 , StatusCode :: CONFLICT ) . await ?;
692+ assert_eq ! ( attempts, 1 ) ;
693+ Ok ( ( ) )
694+ }
695+
696+ /// 429 Too Many Requests — `PbsError::should_retry` already excludes this;
697+ /// retrying as JSON would add insult to injury. No retry.
698+ #[ tokio:: test]
699+ async fn test_submit_block_ssz_does_not_retry_on_429 ( ) -> Result < ( ) > {
700+ let ( _res, attempts) =
701+ submit_block_ssz_override ( BuilderApiVersion :: V1 , StatusCode :: TOO_MANY_REQUESTS ) . await ?;
702+ assert_eq ! ( attempts, 1 ) ;
703+ Ok ( ( ) )
704+ }
705+
706+ /// Same policy applies to the v2 endpoint.
707+ #[ tokio:: test]
708+ async fn test_submit_block_v2_ssz_retries_as_json_on_415 ( ) -> Result < ( ) > {
709+ let ( res, attempts) =
710+ submit_block_ssz_override ( BuilderApiVersion :: V2 , StatusCode :: UNSUPPORTED_MEDIA_TYPE )
711+ . await ?;
712+ assert_eq ! ( res. status( ) , StatusCode :: ACCEPTED , "v2 success is 202 Accepted" ) ;
713+ assert_eq ! ( attempts, 2 ) ;
714+ Ok ( ( ) )
715+ }
716+
717+ /// v2 + 400: same no-retry rule as v1.
718+ #[ tokio:: test]
719+ async fn test_submit_block_v2_ssz_does_not_retry_on_400 ( ) -> Result < ( ) > {
720+ let ( _res, attempts) =
721+ submit_block_ssz_override ( BuilderApiVersion :: V2 , StatusCode :: BAD_REQUEST ) . await ?;
722+ assert_eq ! ( attempts, 1 ) ;
723+ Ok ( ( ) )
724+ }
0 commit comments