This is an automated email from the ASF dual-hosted git repository.

bneradt pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new 6610bd8d41 escalate.so: --escalate-non-get-methods (#12448)
6610bd8d41 is described below

commit 6610bd8d415b78b61cbf7b52bb4786789f6613ad
Author: Brian Neradt <[email protected]>
AuthorDate: Fri Aug 15 14:30:54 2025 -0500

    escalate.so: --escalate-non-get-methods (#12448)
    
    Implement --escalate-non-get-methods for the escalate plugin. By
    default, only GET methods are escalated because typical failover servers
    are caches of the original and cannot handle non-idempotent requests.
    However, for situations where non-GET is supported, this method allows
    the admin to escalate those requests as well.
---
 doc/admin-guide/plugins/escalate.en.rst            |  14 +++
 plugins/escalate/escalate.cc                       |  39 +++++-
 .../pluginTest/escalate/escalate.test.py           | 132 ++++++++++++++++++++-
 .../escalate/escalate_failover.replay.yaml         |  25 ++++
 ...y.yaml => escalate_non_get_methods.replay.yaml} |  71 ++++++++++-
 .../escalate/escalate_original.replay.yaml         |  71 +++++++++++
 6 files changed, 348 insertions(+), 4 deletions(-)

diff --git a/doc/admin-guide/plugins/escalate.en.rst 
b/doc/admin-guide/plugins/escalate.en.rst
index 56ec1fde07..bf65ee3f13 100644
--- a/doc/admin-guide/plugins/escalate.en.rst
+++ b/doc/admin-guide/plugins/escalate.en.rst
@@ -43,6 +43,14 @@ when the origin server in the remap rule returns a 401,
   This option sends the "pristine" Host: header (eg, the Host: header
   that the client sent) to the escalated request.
 
+@pparam=--escalate-non-get-methods
+  In general, the escalate plugin is used with a failover origin that serves a
+  cached backup of the original content.  As a result, the default behavior is
+  to only escalate GET requests since POST, PUT, etc., are not idempotent and
+  may require side effects that are not supported by a failover origin. This
+  option overrides the default behavior and enables escalation for non-GET
+  requests in addition to GET.
+
 Installation
 ------------
 
@@ -61,3 +69,9 @@ Traffic Server would accept a request for ``cdn.example.com`` 
and, on a cache mi
 request to ``origin.example.com``. If the response code from that server is a 
401, 404, 410,
 or 502, then Traffic Server would proxy the request to 
``second-origin.example.com``, using a
 Host: header of ``cdn.example.com``.
+
+By default, only GET requests are escalated. To escalate non-GET requests as
+well, you can use::
+
+    map cdn.example.com origin.example.com \
+      @plugin=escalate.so @pparam=401,404,410,502:second-origin.example.com 
@pparam=--escalate-non-get-methods
diff --git a/plugins/escalate/escalate.cc b/plugins/escalate/escalate.cc
index 0f1104ffff..17ff8a40e3 100644
--- a/plugins/escalate/escalate.cc
+++ b/plugins/escalate/escalate.cc
@@ -20,13 +20,16 @@
   See the License for the specific language governing permissions and
   limitations under the License.
  */
+#include "ts/apidefs.h"
 #include <ts/ts.h>
 #include <ts/remap.h>
+
 #include <cstdlib>
 #include <cstdio>
 #include <getopt.h>
 #include <cstring>
 #include <string>
+#include <string_view>
 #include <iterator>
 #include <map>
 
@@ -67,7 +70,8 @@ struct EscalationState {
   ~EscalationState() { TSContDestroy(cont); }
   TSCont        cont;
   StatusMapType status_map;
-  bool          use_pristine = false;
+  bool          use_pristine             = false;
+  bool          escalate_non_get_methods = false;
 };
 
 // Little helper function, to update the Host portion of a URL, and stringify 
the result.
@@ -92,6 +96,31 @@ MakeEscalateUrl(TSMBuffer mbuf, TSMLoc url, const char 
*host, size_t host_len, i
   return url_str;
 }
 
+/**
+ * Check if the method is GET.
+ *
+ * @param txn The transaction whose method is to be checked.
+ * @return True if @a txn's method is GET, false otherwise.
+ */
+static bool
+MethodIsGet(TSHttpTxn txn)
+{
+  TSMBuffer req_mbuf;
+  TSMLoc    req_hdrp;
+  if (TSHttpTxnClientReqGet(txn, &req_mbuf, &req_hdrp) != TS_SUCCESS) {
+    return false;
+  }
+  int         method_len = 0;
+  char const *method     = TSHttpHdrMethodGet(req_mbuf, req_hdrp, &method_len);
+  if (method == nullptr) {
+    return false;
+  }
+  std::string_view method_view{method, static_cast<size_t>(method_len)};
+  bool const       is_get = (method_view == TS_HTTP_METHOD_GET);
+  TSHandleMLocRelease(req_mbuf, TS_NULL_MLOC, req_hdrp);
+  return is_get;
+}
+
 
//////////////////////////////////////////////////////////////////////////////////////////
 // Main continuation for the plugin, examining an origin response for a 
potential retry.
 //
@@ -106,6 +135,12 @@ EscalateResponse(TSCont cont, TSEvent event, void *edata)
   TSAssert(event == TS_EVENT_HTTP_READ_RESPONSE_HDR || event == 
TS_EVENT_HTTP_SEND_RESPONSE_HDR);
   bool const processing_connection_error = (event == 
TS_EVENT_HTTP_SEND_RESPONSE_HDR);
 
+  if (!es->escalate_non_get_methods && !MethodIsGet(txn)) {
+    Dbg(dbg_ctl, "Skipping escalation for non-GET method");
+    TSHttpTxnReenable(txn, TS_EVENT_HTTP_CONTINUE);
+    return TS_EVENT_NONE;
+  }
+
   if (processing_connection_error) {
     TSServerState const state = TSHttpTxnServerStateGet(txn);
     if (state == TS_SRVSTATE_CONNECTION_ALIVE) {
@@ -199,6 +234,8 @@ TSRemapNewInstance(int argc, char *argv[], void **instance, 
char *errbuf, int er
     // Ugly, but we set the precedence before with non-command line parsing of 
args
     if (0 == strncasecmp(argv[i], "--pristine", 10)) {
       es->use_pristine = true;
+    } else if (0 == strncasecmp(argv[i], "--escalate-non-get-methods", 26)) {
+      es->escalate_non_get_methods = true;
     } else {
       // Each token should be a status code then a URL, separated by ':'.
       sep = strchr(argv[i], ':');
diff --git a/tests/gold_tests/pluginTest/escalate/escalate.test.py 
b/tests/gold_tests/pluginTest/escalate/escalate.test.py
index 68c4e7a915..bd3e5675fc 100644
--- a/tests/gold_tests/pluginTest/escalate/escalate.test.py
+++ b/tests/gold_tests/pluginTest/escalate/escalate.test.py
@@ -1,4 +1,5 @@
 '''
+Verify escalate plugin behavior.
 '''
 #  Licensed to the Apache Software Foundation (ASF) under one
 #  or more contributor license agreements.  See the NOTICE file
@@ -28,7 +29,7 @@ Test.SkipUnless(Condition.PluginExists('escalate.so'))
 
 class EscalateTest:
     """
-    Test the escalate plugin.
+    Test the escalate plugin default behavior (GET requests only).
     """
 
     _replay_original_file: str = 'escalate_original.replay.yaml'
@@ -65,6 +66,10 @@ class EscalateTest:
             'uuid: GET_chunked', "Verify the origin server GET request for 
chunked content.")
         self._server_origin.Streams.All += Testers.ContainsExpression(
             'uuid: GET_failed', "Verify the origin server received the GET 
request that it returns a 502 with.")
+        self._server_origin.Streams.All += Testers.ContainsExpression(
+            'uuid: HEAD_fail_not_escalated', "Verify the origin server 
received the HEAD request that should not be escalated.")
+        self._server_origin.Streams.All += Testers.ContainsExpression(
+            'uuid: POST_fail_not_escalated', "Verify the origin server 
received the POST request that should not be escalated.")
         self._server_origin.Streams.All += Testers.ExcludesExpression(
             'uuid: GET_down_origin', "Verify the origin server did not receive 
the down origin request.")
 
@@ -77,6 +82,13 @@ class EscalateTest:
             'x-request: first', "Verify the failover server did not receive 
the GET request.")
         self._server_failover.Streams.All += Testers.ExcludesExpression(
             'uuid: GET_chunked', "Verify the failover server did not receive 
the GET request for chunked content.")
+        # By default, non-GET methods should NOT be escalated to failover
+        self._server_failover.Streams.All += Testers.ExcludesExpression(
+            'uuid: HEAD_fail_not_escalated',
+            "Verify the failover server did not receive the HEAD request that 
should not be escalated.")
+        self._server_failover.Streams.All += Testers.ExcludesExpression(
+            'uuid: POST_fail_not_escalated',
+            "Verify the failover server did not receive the POST request that 
should not be escalated.")
 
     def _setup_ts(self, tr: 'Process') -> None:
         '''Set up Traffic Server.
@@ -119,12 +131,128 @@ class EscalateTest:
 
         client.Streams.All += Testers.ExcludesExpression(r'\[ERROR\]', 'Verify 
there were no errors in the replay.')
         client.Streams.All += Testers.ExcludesExpression('400 Bad', 'Verify 
none of the 400 responses make it to the client.')
-        client.Streams.All += Testers.ExcludesExpression('502 Bad', 'Verify 
none of the 502 responses make it to the client.')
         client.Streams.All += Testers.ExcludesExpression('500 Internal', 
'Verify none of the 500 responses make it to the client.')
+        # GET requests should be escalated and return 200
         client.Streams.All += Testers.ContainsExpression('x-response: first', 
'Verify that the first response was received.')
         client.Streams.All += Testers.ContainsExpression('x-response: second', 
'Verify that the second response was received.')
         client.Streams.All += Testers.ContainsExpression('x-response: third', 
'Verify that the third response was received.')
         client.Streams.All += Testers.ContainsExpression('x-response: fourth', 
'Verify that the fourth response was received.')
+        # Non-GET requests should NOT be escalated and return 502 (default 
behavior)
+        client.Streams.All += Testers.ContainsExpression(
+            'x-response: head_fail_not_escalated', 'Verify that the HEAD 
response was received (502).')
+        client.Streams.All += Testers.ContainsExpression(
+            'x-response: post_fail_not_escalated', 'Verify that the POST 
response was received (502).')
+        client.Streams.All += Testers.ContainsExpression(
+            '502 Bad Gateway', 'Verify that non-GET requests return 502 (not 
escalated by default).')
+
+
+class EscalateNonGetMethodsTest:
+    """
+    Test the escalate plugin with --escalate-non-get-methods option to verify 
non-GET requests are also escalated.
+    """
+
+    _replay_get_method_file: str = 'escalate_non_get_methods.replay.yaml'
+    _replay_failover_file: str = 'escalate_failover.replay.yaml'
+
+    def __init__(self):
+        '''Configure the test run for escalating non-GET methods testing.'''
+        tr = Test.AddTestRun('Test escalate plugin with 
--escalate-non-get-methods option.')
+        self._setup_dns(tr)
+        self._setup_servers(tr)
+        self._setup_ts(tr)
+        self._setup_client(tr)
+
+    def _setup_dns(self, tr: 'TestRun') -> None:
+        '''Set up the DNS server.'''
+        self._dns = tr.MakeDNServer("dns_non_get_methods", default='127.0.0.1')
+
+    def _setup_servers(self, tr: 'TestRun') -> None:
+        '''Set up the origin and failover servers for non-GET methods 
testing.'''
+        tr.Setup.Copy(self._replay_get_method_file)
+        tr.Setup.Copy(self._replay_failover_file)
+        self._server_origin = 
tr.AddVerifierServerProcess("server_origin_non_get_methods", 
self._replay_get_method_file)
+        self._server_failover = 
tr.AddVerifierServerProcess("server_failover_non_get_methods", 
self._replay_failover_file)
+
+        # Verify the origin server received all requests
+        self._server_origin.Streams.All += Testers.ContainsExpression(
+            'uuid: GET', "Verify the origin server received the first GET 
request.")
+        self._server_origin.Streams.All += Testers.ContainsExpression(
+            'uuid: GET_chunked', "Verify the origin server received the 
chunked GET request.")
+        self._server_origin.Streams.All += Testers.ContainsExpression(
+            'uuid: GET_failed', "Verify the origin server received the failed 
GET request.")
+        self._server_origin.Streams.All += Testers.ContainsExpression(
+            'uuid: POST_success', "Verify the origin server received the 
successful POST request.")
+        self._server_origin.Streams.All += Testers.ContainsExpression(
+            'uuid: HEAD_fail_escalated', "Verify the origin server received 
the HEAD request that will be escalated.")
+
+        # The down origin request should NOT be received by this server
+        self._server_origin.Streams.All += Testers.ExcludesExpression(
+            'uuid: GET_down_origin', "Verify the origin server did not receive 
the down origin request.")
+
+        # Verify failover server receives escalated requests including non-GET 
methods
+        self._server_failover.Streams.All += Testers.ContainsExpression(
+            'uuid: GET_failed', "Verify the failover server received the 
failed GET request.")
+        self._server_failover.Streams.All += Testers.ContainsExpression(
+            'uuid: GET_down_origin', "Verify the failover server received the 
down origin GET request.")
+        # With --escalate-non-get-methods, the HEAD request should now be 
escalated
+        self._server_failover.Streams.All += Testers.ContainsExpression(
+            'uuid: HEAD_fail_escalated', "Verify the failover server received 
the HEAD that is now escalated.")
+        # The successful POST should also not reach failover (since it 
succeeds on origin)
+        self._server_failover.Streams.All += Testers.ExcludesExpression(
+            'uuid: POST_success', "Verify the failover server did not receive 
the successful POST request.")
+
+    def _setup_ts(self, tr: 'TestRun') -> None:
+        '''Set up Traffic Server with --escalate-non-get-methods option.'''
+        self._ts = tr.MakeATSProcess("ts_non_get_methods", enable_cache=False)
+
+        self._ts.Disk.records_config.update(
+            {
+                'proxy.config.diags.debug.enabled': 1,
+                'proxy.config.diags.debug.tags': 'http|escalate',
+                'proxy.config.dns.nameservers': 
f'127.0.0.1:{self._dns.Variables.Port}',
+                'proxy.config.dns.resolv_conf': 'NULL',
+                'proxy.config.http.redirect.actions': 'self:follow',
+                'proxy.config.http.number_of_redirections': 4,
+            })
+
+        # Set up a dead port for the down origin scenario
+        dead_port = get_port(self._ts, "dead_port")
+
+        # Configure escalate plugin with --escalate-non-get-methods option
+        self._ts.Disk.remap_config.AddLines(
+            [
+                f'map http://origin.server.com 
http://backend.origin.server.com:{self._server_origin.Variables.http_port} '
+                f'@plugin=escalate.so 
@pparam=500,502:failover.server.com:{self._server_failover.Variables.http_port} 
@pparam=--escalate-non-get-methods',
+                f'map http://down_origin.server.com 
http://backend.down_origin.server.com:{dead_port} '
+                f'@plugin=escalate.so 
@pparam=500,502:failover.server.com:{self._server_failover.Variables.http_port} 
@pparam=--escalate-non-get-methods',
+            ])
+
+    def _setup_client(self, tr: 'TestRun') -> None:
+        '''Set up the client for non-GET methods testing.'''
+        client = tr.AddVerifierClientProcess(
+            "client_non_get_methods", self._replay_get_method_file, 
http_ports=[self._ts.Variables.port])
+
+        client.StartBefore(self._dns)
+        client.StartBefore(self._server_origin)
+        client.StartBefore(self._server_failover)
+        client.StartBefore(self._ts)
+
+        # Verify that successful responses are returned for successful 
requests and escalated failures
+        client.Streams.All += Testers.ContainsExpression('x-response: first', 
'Verify first GET response received.')
+        client.Streams.All += Testers.ContainsExpression('x-response: second', 
'Verify second GET response received.')
+        client.Streams.All += Testers.ContainsExpression('x-response: third', 
'Verify third GET response received (escalated).')
+        client.Streams.All += Testers.ContainsExpression('x-response: fourth', 
'Verify fourth GET response received (escalated).')
+        client.Streams.All += Testers.ContainsExpression('x-response: 
post_success', 'Verify successful POST response received.')
+        client.Streams.All += Testers.ContainsExpression(
+            'x-response: head_fail_escalated', 'Verify escalated HEAD response 
received.')
+
+        # With --escalate-non-get-methods, POST and HEAD failures should now 
be escalated and return 200
+        client.Streams.All += Testers.ExcludesExpression(
+            '502 Bad Gateway', 'Verify failed POST and HEAD requests are now 
escalated')
+
+        # The test should complete without errors
+        client.Streams.All += Testers.ExcludesExpression(r'\[ERROR\]', 'Verify 
there were no errors in the replay.')
 
 
 EscalateTest()
+EscalateNonGetMethodsTest()
diff --git a/tests/gold_tests/pluginTest/escalate/escalate_failover.replay.yaml 
b/tests/gold_tests/pluginTest/escalate/escalate_failover.replay.yaml
index 3adffddeda..04fabbb7a7 100644
--- a/tests/gold_tests/pluginTest/escalate/escalate_failover.replay.yaml
+++ b/tests/gold_tests/pluginTest/escalate/escalate_failover.replay.yaml
@@ -124,3 +124,28 @@ sessions:
         - [ X-Response, fourth ]
       content:
         size: 320000
+
+  # HEAD request response for escalated requests (with 
--escalate-non-get-methods)
+  - client-request:
+      method: "HEAD"
+      version: "1.1"
+      url: /api/head/data
+      headers:
+        fields:
+        - [ Host, origin.server.com ]
+        - [ X-Request, head_fail_escalated ]
+        - [ uuid, HEAD_fail_escalated ]
+
+    proxy-request:
+      method: "HEAD"
+      headers:
+        fields:
+        - [ X-Request, { value: head_fail_escalated, as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 0 ]
+        - [ X-Response, head_fail_escalated ]
diff --git a/tests/gold_tests/pluginTest/escalate/escalate_original.replay.yaml 
b/tests/gold_tests/pluginTest/escalate/escalate_non_get_methods.replay.yaml
similarity index 65%
copy from tests/gold_tests/pluginTest/escalate/escalate_original.replay.yaml
copy to 
tests/gold_tests/pluginTest/escalate/escalate_non_get_methods.replay.yaml
index 898464bee2..638a8c7b04 100644
--- a/tests/gold_tests/pluginTest/escalate/escalate_original.replay.yaml
+++ b/tests/gold_tests/pluginTest/escalate/escalate_non_get_methods.replay.yaml
@@ -19,6 +19,7 @@ meta:
 
 sessions:
 - transactions:
+  # Original GET transactions from escalate_original.replay.yaml
   - client-request:
       method: "GET"
       version: "1.1"
@@ -108,7 +109,7 @@ sessions:
         - [ Content-Length, 0 ]
 
     proxy-response:
-      # The failover server should reply with a 200 OK.
+      # The failover server should reply with a 200 OK (GET requests are 
escalated with --escalate-non-get-methods).
       status: 200
       headers:
         fields:
@@ -145,3 +146,71 @@ sessions:
       headers:
         fields:
         - [ X-Response, { value: fourth, as: equal } ]
+
+  # POST request with sizable body that gets proxied normally.
+  - client-request:
+      method: "POST"
+      version: "1.1"
+      url: /api/upload/data
+      headers:
+        fields:
+        - [ Host, origin.server.com ]
+        - [ Content-Type, "application/json" ]
+        - [ Content-Length, 32 ]
+        - [ X-Request, post_success ]
+        - [ uuid, POST_success ]
+      content:
+        encoding: plain
+        size: 32
+
+    proxy-request:
+      method: "POST"
+      headers:
+        fields:
+        - [ X-Request, { value: post_success, as: equal } ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Length, 32 ]
+        - [ X-Response, post_success ]
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Response, { value: post_success, as: equal } ]
+
+  # A HEAD request that will be escalated with --escalate-non-get-methods
+  - client-request:
+      method: "HEAD"
+      version: "1.1"
+      url: /api/head/data
+      headers:
+        fields:
+        - [ Host, origin.server.com ]
+        - [ X-Request, head_fail_escalated ]
+        - [ uuid, HEAD_fail_escalated ]
+
+    proxy-request:
+      method: "HEAD"
+      headers:
+        fields:
+        - [ X-Request, { value: head_fail_escalated, as: equal } ]
+
+    server-response:
+      status: 502
+      reason: Bad Gateway
+      headers:
+        fields:
+        - [ Content-Length, 0 ]
+        - [ X-Response, head_fail_escalated ]
+
+    # With --escalate-non-get-methods, HEAD request is escalated to failover.
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ X-Response, { value: head_fail_escalated, as: equal } ]
diff --git a/tests/gold_tests/pluginTest/escalate/escalate_original.replay.yaml 
b/tests/gold_tests/pluginTest/escalate/escalate_original.replay.yaml
index 898464bee2..9337b86525 100644
--- a/tests/gold_tests/pluginTest/escalate/escalate_original.replay.yaml
+++ b/tests/gold_tests/pluginTest/escalate/escalate_original.replay.yaml
@@ -145,3 +145,74 @@ sessions:
       headers:
         fields:
         - [ X-Response, { value: fourth, as: equal } ]
+
+  # HEAD request that should NOT be escalated by default (only GET methods are 
escalated)
+  - client-request:
+      method: "HEAD"
+      version: "1.1"
+      url: /api/head/fail_not_escalated
+      headers:
+        fields:
+        - [ Host, origin.server.com ]
+        - [ X-Request, head_fail_not_escalated ]
+        - [ uuid, HEAD_fail_not_escalated ]
+
+    proxy-request:
+      method: "HEAD"
+      headers:
+        fields:
+        - [ X-Request, { value: head_fail_not_escalated, as: equal } ]
+
+    server-response:
+      status: 502
+      reason: Bad Gateway
+      headers:
+        fields:
+        - [ Content-Length, 0 ]
+        - [ X-Response, head_fail_not_escalated ]
+
+    # By default, non-GET methods are not escalated.
+    proxy-response:
+      status: 502
+      headers:
+        fields:
+        - [ Content-Length, 0 ]
+        - [ X-Response, { value: head_fail_not_escalated, as: equal } ]
+
+  # POST request that should NOT be escalated by default (only GET methods are 
escalated)
+  - client-request:
+      method: "POST"
+      version: "1.1"
+      url: /api/post/fail_not_escalated
+      headers:
+        fields:
+        - [ Host, origin.server.com ]
+        - [ Content-Type, "application/json" ]
+        - [ Content-Length, 1234 ]
+        - [ X-Request, post_fail_not_escalated ]
+        - [ uuid, POST_fail_not_escalated ]
+      content:
+        encoding: plain
+        size: 1234
+
+    proxy-request:
+      method: "POST"
+      headers:
+        fields:
+        - [ X-Request, { value: post_fail_not_escalated, as: equal } ]
+
+    server-response:
+      status: 502
+      reason: Bad Gateway
+      headers:
+        fields:
+        - [ Content-Length, 0 ]
+        - [ X-Response, post_fail_not_escalated ]
+
+    # By default, non-GET methods are not escalated.
+    proxy-response:
+      status: 502
+      headers:
+        fields:
+        - [ Content-Length, 0 ]
+        - [ X-Response, { value: post_fail_not_escalated, as: equal } ]

Reply via email to