paivana

HTTP paywall reverse proxy
Log | Files | Refs | Submodules | README | LICENSE

payment_backend_stub.py (5249B)


      1 #!/usr/bin/env python3
      2 """Small merchant HTTP stub for payment-backend failure tests."""
      3 
      4 import json
      5 import os
      6 import sys
      7 import threading
      8 import time
      9 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
     10 from urllib.parse import urlsplit
     11 
     12 
     13 TOKEN = "secret-token:stub"
     14 TEMPLATE_ID = "premium"
     15 TEMPLATE_COUNT = int(os.environ.get("PAIVANA_STUB_TEMPLATE_COUNT", "1"))
     16 CONTRACT_PADDING = int(os.environ.get("PAIVANA_STUB_CONTRACT_PADDING", "0"))
     17 DETAIL_DELAY = float(os.environ.get("PAIVANA_STUB_DETAIL_DELAY", "0"))
     18 BASE_PATH = os.environ.get("PAIVANA_STUB_BASE_PATH", "").rstrip("/")
     19 detail_lock = threading.Lock()
     20 active_details = 0
     21 retry_order_dropped = False
     22 retry_order_lock = threading.Lock()
     23 
     24 
     25 def template_ids():
     26     if TEMPLATE_COUNT == 1:
     27         return [TEMPLATE_ID]
     28     return [f"{TEMPLATE_ID}-{i:04d}" for i in range(TEMPLATE_COUNT)]
     29 
     30 
     31 class ReusableServer(ThreadingHTTPServer):
     32     allow_reuse_address = True
     33     daemon_threads = True
     34 
     35 
     36 class Handler(BaseHTTPRequestHandler):
     37     protocol_version = "HTTP/1.1"
     38 
     39     def log_message(self, fmt, *args):
     40         sys.stderr.write("payment_backend_stub: " + fmt % args + "\n")
     41 
     42     def reply(self, status, body):
     43         encoded = json.dumps(body, separators=(",", ":")).encode()
     44         self.send_response(status)
     45         self.send_header("Content-Type", "application/json")
     46         self.send_header("Content-Length", str(len(encoded)))
     47         self.end_headers()
     48         self.wfile.write(encoded)
     49 
     50     def do_GET(self):
     51         if self.headers.get("Authorization") != f"Bearer {TOKEN}":
     52             self.reply(401, {"code": 2000, "hint": "wrong bearer token"})
     53             return
     54         path = urlsplit(self.path).path
     55         if BASE_PATH:
     56             if not path.startswith(BASE_PATH + "/"):
     57                 self.reply(404, {"code": 2906, "hint": "wrong base path"})
     58                 return
     59             path = path[len(BASE_PATH):]
     60         if path == "/private/templates":
     61             self.reply(
     62                 200,
     63                 {
     64                     "templates": [
     65                         {
     66                             "template_id": template_id,
     67                             "template_description": "Paywalled content",
     68                         }
     69                         for template_id in template_ids()
     70                     ]
     71                 },
     72             )
     73             return
     74         if path.removeprefix("/private/templates/") in template_ids():
     75             global active_details
     76             with detail_lock:
     77                 active_details += 1
     78                 current = active_details
     79                 print(f"template detail concurrency {current}", flush=True)
     80             try:
     81                 if DETAIL_DELAY:
     82                     time.sleep(DETAIL_DELAY)
     83                 self.reply(
     84                     200,
     85                     {
     86                         "template_description": "Paywalled content",
     87                         "template_contract": {
     88                             "template_type": "paivana",
     89                             "summary": "Access to the article" + "x" * CONTRACT_PADDING,
     90                             "website_regex": ".*",
     91                             "max_pickup_duration": {"d_us": 3600000000},
     92                             "choices": [
     93                                 {"amount": "TESTKUDOS:1", "description": "One article"}
     94                             ],
     95                         },
     96                     },
     97                 )
     98             finally:
     99                 with detail_lock:
    100                     active_details -= 1
    101             return
    102         if path == "/private/orders/timeout-order":
    103             # Longer than Paivana's five-second order deadline.  The
    104             # client will close the connection before this reply.
    105             time.sleep(10)
    106             self.reply(404, {"code": 2906, "hint": "late test reply"})
    107             return
    108         if path == "/private/orders/retry-order":
    109             global retry_order_dropped
    110             with retry_order_lock:
    111                 drop = not retry_order_dropped
    112                 retry_order_dropped = True
    113             if drop:
    114                 # Simulate a pooled connection that the backend closed while
    115                 # it was idle: the request reached the server, but no HTTP
    116                 # response reached Paivana.  The idempotent lookup should be
    117                 # retried once on another connection.
    118                 print("dropping first /private/orders/retry-order request",
    119                       flush=True)
    120                 self.close_connection = True
    121                 self.connection.shutdown(2)
    122                 self.connection.close()
    123                 return
    124             self.reply(404, {"code": 2906, "hint": "unknown test order"})
    125             return
    126         if path.startswith("/private/orders/"):
    127             self.reply(404, {"code": 2906, "hint": "unknown test order"})
    128             return
    129         self.reply(404, {"code": 2906, "hint": "unknown test endpoint"})
    130 
    131 
    132 def main():
    133     if len(sys.argv) != 2:
    134         raise SystemExit(f"usage: {sys.argv[0]} PORT")
    135     server = ReusableServer(("127.0.0.1", int(sys.argv[1])), Handler)
    136     print(f"payment_backend_stub listening on {server.server_port}", flush=True)
    137     server.serve_forever()
    138 
    139 
    140 if __name__ == "__main__":
    141     main()