paivana

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

commit 92e1a4ebf55100e9b831ac621b3c9c4ae83511d6
parent 55cf983d4b53dbd4c6d0b9494cb37ed8ec4b9998
Author: Florian Dold <dold@taler.net>
Date:   Wed, 26 Aug 2026 01:56:13 +0200

payment: retry merchant lookups and report HTTP status

Diffstat:
MNEWS | 7+++++++
Msrc/backend/paivana-httpd_pay.c | 288++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Msrc/tests/README | 19+++++++++++--------
Msrc/tests/payment_backend_stub.py | 20++++++++++++++++++++
Msrc/tests/test_payment_backend_failure.sh | 28+++++++++++++++++++++++-----
5 files changed, 280 insertions(+), 82 deletions(-)

diff --git a/NEWS b/NEWS @@ -1,4 +1,11 @@ Unreleased: + - Payment confirmation retries an order-status GET once when the merchant + connection fails before returning any HTTP response. The retry stays + within the original five-second deadline, preventing a stale pooled + connection from intermittently turning a completed payment into error + 9801. Merchant-related error JSON now includes `merchant_http_status', + with zero meaning that no HTTP response was received. + - High-load operation is now fail-fast and bounded. CONNECTION_LIMIT defaults to 384 and may not exceed the select()-safe descriptor budget; PAYMENT_CONNECTION_LIMIT reserves 32 request slots for redemptions, and diff --git a/src/backend/paivana-httpd_pay.c b/src/backend/paivana-httpd_pay.c @@ -66,6 +66,13 @@ struct PayRequest; #define MERCHANT_ORDER_TIMEOUT \ GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_SECONDS, 5) +/** + * Do not start a retry when too little of #MERCHANT_ORDER_TIMEOUT remains to + * make another connection useful. + */ +#define MERCHANT_RETRY_MIN_BUDGET \ + GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_MILLISECONDS, 250) + /** * Handle for processing actual payment. @@ -114,6 +121,14 @@ struct PayRequest struct GNUNET_TIME_Absolute merchant_request_started; /** + * Number of merchant lookup attempts started for this redemption. A GET + * that fails before receiving any HTTP response is retried once: the + * merchant connection pool can otherwise turn one stale idle connection + * into a user-visible payment failure. + */ + unsigned int merchant_lookup_attempts; + + /** * Response to return, NULL if not yet determined. */ struct MHD_Response *response; @@ -428,6 +443,120 @@ under_our_base_url (const char *website) } +static void +order_status_cb (struct PayRequest *ph, + const struct TALER_MERCHANT_GetPrivateOrderResponse *osr); + + +/** + * Start one attempt to retrieve the order from the merchant backend. + * + * The caller owns the request's DLL membership and suspended MHD connection. + * On failure this function leaves @e ph->co NULL. + * + * @param ph payment request + * @param timeout remaining overall timeout for this attempt + * @return #TALER_EC_NONE on success, an error code otherwise + */ +static enum TALER_ErrorCode +start_merchant_order_lookup (struct PayRequest *ph, + struct GNUNET_TIME_Relative timeout) +{ + char *paivana_id; + enum GNUNET_GenericReturnValue ret; + enum TALER_ErrorCode ec; + + GNUNET_assert (NULL == ph->co); + ph->co = TALER_MERCHANT_get_private_order_create (PH_merchant_ctx, + PH_merchant_base_url, + ph->order_id); + if (NULL == ph->co) + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + paivana_id = PAIVANA_HTTPD_compute_paivana_id (ph->expiration, + ph->website, + &ph->nonce); + ret = TALER_MERCHANT_get_private_order_set_options ( + ph->co, + TALER_MERCHANT_get_private_order_option_session_id (paivana_id), + TALER_MERCHANT_get_private_order_option_timeout (timeout)); + GNUNET_free (paivana_id); + if (GNUNET_OK != ret) + { + TALER_MERCHANT_get_private_order_cancel (ph->co); + ph->co = NULL; + return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; + } + ec = TALER_MERCHANT_get_private_order_start (ph->co, + &order_status_cb, + ph); + if (TALER_EC_NONE != ec) + { + TALER_MERCHANT_get_private_order_cancel (ph->co); + ph->co = NULL; + return ec; + } + ph->merchant_lookup_attempts++; + return TALER_EC_NONE; +} + + +/** + * Build a Paivana error response caused by a merchant order lookup. + * + * Keep the standard Taler error shape and its existing @e detail while adding + * the status of the merchant request. A zero status explicitly says that no + * HTTP response was received. + * + * @param ec Taler error code + * @param detail optional error detail + * @param merchant_http_status HTTP status returned by the merchant, or zero + * @return MHD response + */ +static struct MHD_Response * +make_merchant_error (enum TALER_ErrorCode ec, + const char *detail, + unsigned int merchant_http_status) +{ + return TALER_MHD_MAKE_JSON_PACK ( + TALER_MHD_PACK_EC (ec), + GNUNET_JSON_pack_conditional ( + NULL != detail, + GNUNET_JSON_pack_string ("detail", + detail)), + GNUNET_JSON_pack_uint64 ("merchant_http_status", + merchant_http_status)); +} + + +/** + * Queue a Paivana error caused before a merchant lookup returned. + * + * @param connection client connection + * @param http_status status to return to the client + * @param ec Taler error code + * @param detail optional error detail + * @return MHD result + */ +static enum MHD_Result +reply_with_merchant_error (struct MHD_Connection *connection, + unsigned int http_status, + enum TALER_ErrorCode ec, + const char *detail) +{ + struct MHD_Response *response; + enum MHD_Result ret; + + response = make_merchant_error (ec, + detail, + 0); + ret = MHD_queue_response (connection, + http_status, + response); + MHD_destroy_response (response); + return ret; +} + + /** * Check that the @a contract that was paid is reasonable for the * request in @a ph, that is that we would indeed consider this @@ -479,8 +608,9 @@ check_contract (struct PayRequest *ph, fields is already what GNUNET_JSON_parse() does; a failure here means a field we do look at was malformed. */ GNUNET_break_op (0); - ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_WRONG_ORDER, - ph->order_id); + ph->response = make_merchant_error (TALER_EC_PAIVANA_WRONG_ORDER, + ph->order_id, + MHD_HTTP_OK); ph->response_status = MHD_HTTP_CONFLICT; return false; } @@ -489,8 +619,9 @@ check_contract (struct PayRequest *ph, ph->website)) ) { GNUNET_break_op (0); - ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_WRONG_ORDER, - ph->order_id); + ph->response = make_merchant_error (TALER_EC_PAIVANA_WRONG_ORDER, + ph->order_id, + MHD_HTTP_OK); ph->response_status = MHD_HTTP_CONFLICT; return false; } @@ -502,8 +633,9 @@ check_contract (struct PayRequest *ph, the target given is not from our domain or not a well-formed URL. Reject hard. */ GNUNET_break_op (0); - ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_INVALID_TARGET, - ph->website); + ph->response = make_merchant_error (TALER_EC_PAIVANA_INVALID_TARGET, + ph->website, + MHD_HTTP_OK); ph->response_status = MHD_HTTP_CONFLICT; return false; } @@ -512,8 +644,9 @@ check_contract (struct PayRequest *ph, max_time)) { GNUNET_break_op (0); - ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_TOO_LATE, - ph->order_id); + ph->response = make_merchant_error (TALER_EC_PAIVANA_TOO_LATE, + ph->order_id, + MHD_HTTP_OK); ph->response_status = MHD_HTTP_GONE; return false; } @@ -548,6 +681,51 @@ order_status_cb (struct PayRequest *ph, true)); active_lookups = active_merchant_lookups; ph->co = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, + "Merchant order lookup attempt %u for `%s' completed with" + " HTTP status %u after %s\n", + ph->merchant_lookup_attempts, + ph->order_id, + osr->hr.http_status, + elapsed_s); + if ( (0 == osr->hr.http_status) && + (NULL == osr->hr.reply) && + (1 == ph->merchant_lookup_attempts) && + GNUNET_TIME_relative_cmp (elapsed, + <, + MERCHANT_ORDER_TIMEOUT) ) + { + struct GNUNET_TIME_Relative remaining + = GNUNET_TIME_relative_subtract (MERCHANT_ORDER_TIMEOUT, + elapsed); + + if (GNUNET_TIME_relative_cmp (remaining, + >=, + MERCHANT_RETRY_MIN_BUDGET)) + { + enum TALER_ErrorCode ec; + + ec = start_merchant_order_lookup (ph, + remaining); + if (TALER_EC_NONE == ec) + { + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Merchant order lookup for `%s' received no HTTP" + " response on its first attempt after %s; retrying once" + " within the original %s deadline\n", + ph->order_id, + elapsed_s, + timeout_s); + GNUNET_free (elapsed_s); + GNUNET_free (timeout_s); + return; + } + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Could not start the retry of merchant order `%s': %d\n", + ph->order_id, + (int) ec); + } + } GNUNET_CONTAINER_DLL_remove (ph_head, ph_tail, ph); @@ -555,12 +733,6 @@ order_status_cb (struct PayRequest *ph, active_merchant_lookups--; MHD_resume_connection (ph->connection); TALER_MHD_daemon_trigger (); - GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, - "Merchant order lookup for `%s' completed with HTTP status" - " %u after %s\n", - ph->order_id, - osr->hr.http_status, - elapsed_s); if (0 != osr->hr.http_status) { unsigned int suppressed = reset_merchant_failure_logs (); @@ -590,8 +762,9 @@ order_status_cb (struct PayRequest *ph, (osr->details.ok.details.paid.refund_pending) ) { GNUNET_break_op (0); - ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_PAYMENT_MISSING, - ph->order_id); + ph->response = make_merchant_error (TALER_EC_PAIVANA_PAYMENT_MISSING, + ph->order_id, + MHD_HTTP_OK); ph->response_status = MHD_HTTP_CONFLICT; } else @@ -623,9 +796,10 @@ order_status_cb (struct PayRequest *ph, &ca_len)) { GNUNET_break (0); - ph->response = TALER_MHD_make_error ( + ph->response = make_merchant_error ( TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, - ph->order_id); + ph->order_id, + MHD_HTTP_OK); ph->response_status = MHD_HTTP_INTERNAL_SERVER_ERROR; break; } @@ -661,9 +835,10 @@ order_status_cb (struct PayRequest *ph, GNUNET_break (0); MHD_destroy_response (resp); GNUNET_free (cookie); - ph->response = TALER_MHD_make_error ( + ph->response = make_merchant_error ( TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, - ph->website); + ph->website, + MHD_HTTP_OK); ph->response_status = MHD_HTTP_INTERNAL_SERVER_ERROR; break; } @@ -696,13 +871,15 @@ order_status_cb (struct PayRequest *ph, " %u); check MERCHANT_ACCESS_TOKEN\n", PH_merchant_base_url, osr->hr.http_status); - ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_BACKEND_REFUSED, - NULL); + ph->response = make_merchant_error (TALER_EC_PAIVANA_BACKEND_REFUSED, + NULL, + osr->hr.http_status); ph->response_status = MHD_HTTP_INTERNAL_SERVER_ERROR; break; case MHD_HTTP_NOT_FOUND: - ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_ORDER_UNKNOWN, - ph->order_id); + ph->response = make_merchant_error (TALER_EC_PAIVANA_ORDER_UNKNOWN, + ph->order_id, + osr->hr.http_status); ph->response_status = MHD_HTTP_NOT_FOUND; break; case 0: @@ -727,8 +904,9 @@ order_status_cb (struct PayRequest *ph, elapsed_s, suppressed, (1 == suppressed) ? "" : "s"); - ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_BACKEND_ERROR, - ph->order_id); + ph->response = make_merchant_error (TALER_EC_PAIVANA_BACKEND_ERROR, + ph->order_id, + osr->hr.http_status); ph->response_status = MHD_HTTP_BAD_GATEWAY; break; } @@ -763,8 +941,9 @@ order_status_cb (struct PayRequest *ph, } /* GENERIC_TIMEOUT's hint ("trying again might help") is the one that is true once our own deadline was actually reached. */ - ph->response = TALER_MHD_make_error (TALER_EC_GENERIC_TIMEOUT, - ph->order_id); + ph->response = make_merchant_error (TALER_EC_GENERIC_TIMEOUT, + ph->order_id, + osr->hr.http_status); ph->response_status = MHD_HTTP_GATEWAY_TIMEOUT; } else @@ -799,8 +978,9 @@ order_status_cb (struct PayRequest *ph, suppressed, (1 == suppressed) ? "" : "s"); } - ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_BACKEND_REFUSED, - ph->order_id); + ph->response = make_merchant_error (TALER_EC_PAIVANA_BACKEND_REFUSED, + ph->order_id, + osr->hr.http_status); ph->response_status = MHD_HTTP_BAD_GATEWAY; } break; @@ -815,8 +995,9 @@ order_status_cb (struct PayRequest *ph, sizeof (code), "%u", osr->hr.http_status); - ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_BACKEND_ERROR, - code); + ph->response = make_merchant_error (TALER_EC_PAIVANA_BACKEND_ERROR, + code, + osr->hr.http_status); ph->response_status = MHD_HTTP_BAD_GATEWAY; } break; @@ -909,34 +1090,6 @@ PAIVANA_HTTPD_payment_handle (struct PayRequest *ph, "expiration"); } } - GNUNET_assert (NULL == ph->co); - ph->co = TALER_MERCHANT_get_private_order_create (PH_merchant_ctx, - PH_merchant_base_url, - ph->order_id); - if (NULL == ph->co) - { - GNUNET_break (0); - return TALER_MHD_reply_with_error (ph->connection, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_PAIVANA_GET_ORDER_FAILED, - ph->order_id); - } - { - char *paivana_id; - - paivana_id = PAIVANA_HTTPD_compute_paivana_id (ph->expiration, - ph->website, - &ph->nonce); - GNUNET_assert ( - GNUNET_OK == - TALER_MERCHANT_get_private_order_set_options ( - ph->co, - TALER_MERCHANT_get_private_order_option_session_id ( - paivana_id), - TALER_MERCHANT_get_private_order_option_timeout ( - MERCHANT_ORDER_TIMEOUT))); - GNUNET_free (paivana_id); - } GNUNET_CONTAINER_DLL_insert (ph_head, ph_tail, ph); @@ -946,9 +1099,8 @@ PAIVANA_HTTPD_payment_handle (struct PayRequest *ph, enum TALER_ErrorCode ec; ph->merchant_request_started = GNUNET_TIME_absolute_get (); - ec = TALER_MERCHANT_get_private_order_start (ph->co, - &order_status_cb, - ph); + ec = start_merchant_order_lookup (ph, + MERCHANT_ORDER_TIMEOUT); if (TALER_EC_NONE != ec) { /* Everything the callee can fail on here is a resource failure @@ -963,12 +1115,10 @@ PAIVANA_HTTPD_payment_handle (struct PayRequest *ph, GNUNET_assert (active_merchant_lookups > 0); active_merchant_lookups--; MHD_resume_connection (ph->connection); - TALER_MERCHANT_get_private_order_cancel (ph->co); - ph->co = NULL; - return TALER_MHD_reply_with_error (ph->connection, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_PAIVANA_GET_ORDER_FAILED, - ph->order_id); + return reply_with_merchant_error (ph->connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_PAIVANA_GET_ORDER_FAILED, + ph->order_id); } } return MHD_YES; diff --git a/src/tests/README b/src/tests/README @@ -902,14 +902,17 @@ Payment-backend failure diagnostics ----------------------------------- `test_payment_backend_failure.sh` starts paivana against a small Python -merchant stub, lets template loading finish, and then stops the stub. A -redemption against the now-refused port must return promptly as 502 / error -9801, with the elapsed time and active/consecutive lookup counts in the log; -it must not claim that the five-second deadline elapsed. The stub is then -restarted to prove that a real HTTP response resets the consecutive-failure -count, and finally holds an order lookup open long enough to verify the true -timeout path remains 504 / error 11. It needs neither PostgreSQL nor a full -Taler deployment. +merchant stub and first drops an order lookup before sending any HTTP +response. Paivana must retry that idempotent GET once within its original +five-second budget and return the second attempt's response. The test then +stops the stub: a redemption against the now-refused port must return promptly +as 502 / error 9801, with the elapsed time and active/consecutive lookup counts +in the log and `merchant_http_status: 0' in the error JSON; it must not claim +that the five-second deadline elapsed. The 404 cases likewise assert the +actual merchant status in that field. The stub is restarted to prove that a +real HTTP response resets the consecutive-failure count, and finally holds an +order lookup open long enough to verify the true timeout path remains 504 / +error 11. It needs neither PostgreSQL nor a full Taler deployment. The paywall suite diff --git a/src/tests/payment_backend_stub.py b/src/tests/payment_backend_stub.py @@ -17,6 +17,8 @@ CONTRACT_PADDING = int(os.environ.get("PAIVANA_STUB_CONTRACT_PADDING", "0")) DETAIL_DELAY = float(os.environ.get("PAIVANA_STUB_DETAIL_DELAY", "0")) detail_lock = threading.Lock() active_details = 0 +retry_order_dropped = False +retry_order_lock = threading.Lock() def template_ids(): @@ -98,6 +100,24 @@ class Handler(BaseHTTPRequestHandler): time.sleep(10) self.reply(404, {"code": 2906, "hint": "late test reply"}) return + if path == "/private/orders/retry-order": + global retry_order_dropped + with retry_order_lock: + drop = not retry_order_dropped + retry_order_dropped = True + if drop: + # Simulate a pooled connection that the backend closed while + # it was idle: the request reached the server, but no HTTP + # response reached Paivana. The idempotent lookup should be + # retried once on another connection. + print("dropping first /private/orders/retry-order request", + flush=True) + self.close_connection = True + self.connection.shutdown(2) + self.connection.close() + return + self.reply(404, {"code": 2906, "hint": "unknown test order"}) + return if path.startswith("/private/orders/"): self.reply(404, {"code": 2906, "hint": "unknown test order"}) return diff --git a/src/tests/test_payment_backend_failure.sh b/src/tests/test_payment_backend_failure.sh @@ -83,8 +83,9 @@ function redeem() { } function expect_error() { - local want_code="$1" want_detail="$2" - python3 - "$SCRATCH/response.json" "$want_code" "$want_detail" <<'PY' + local want_code="$1" want_detail="$2" want_merchant_status="$3" + python3 - "$SCRATCH/response.json" "$want_code" "$want_detail" \ + "$want_merchant_status" <<'PY' import json import sys @@ -94,6 +95,11 @@ if body.get("code") != int(sys.argv[2]): raise SystemExit(f"error code {body.get('code')}, want {sys.argv[2]}: {body}") if body.get("detail") != sys.argv[3]: raise SystemExit(f"detail {body.get('detail')!r}, want {sys.argv[3]!r}") +if body.get("merchant_http_status") != int(sys.argv[4]): + raise SystemExit( + f"merchant_http_status {body.get('merchant_http_status')!r}, " + f"want {sys.argv[4]}: {body}" + ) PY } @@ -134,6 +140,18 @@ start_stub PAIVANA_PID=$! wait_for_port "$PAIVANA_PORT" "$PAIVANA_PID" || fail "paivana did not start" +# Losing a merchant connection before any response is safe to retry because +# the order lookup is a GET. The stub drops retry-order's first lookup and +# answers its second, reproducing the stale-idle-connection failure seen in +# production without making the test depend on a TCP keepalive timeout. +read -r status elapsed < <(redeem retry-order) +[ "$status" = "404" ] || fail "retried merchant lookup returned HTTP $status" +expect_error 9802 retry-order 404 || fail "unexpected retry response JSON" +grep -q "retrying once within the original 5 s deadline" \ + "$SCRATCH/paivana.log" || fail "merchant retry diagnostic missing" +[ "$(grep -c '/private/orders/retry-order' "$SCRATCH/merchant.log")" = 2 ] || \ + fail "merchant lookup was not attempted exactly twice" + # A refused connection completes immediately. It must no longer be # presented as a five-second timeout. kill -TERM "$STUB_PID" @@ -141,7 +159,7 @@ wait "$STUB_PID" || true STUB_PID="" read -r status elapsed < <(redeem transport-failure) [ "$status" = "502" ] || fail "early transport failure returned HTTP $status" -expect_error 9801 transport-failure || fail "unexpected early-failure JSON" +expect_error 9801 transport-failure 0 || fail "unexpected early-failure JSON" python3 - "$elapsed" <<'PY' || fail "early failure took $elapsed seconds" import sys raise SystemExit(0 if float(sys.argv[1]) < 2.0 else 1) @@ -156,7 +174,7 @@ grep -q "1 concurrent merchant lookup including this one" "$SCRATCH/paivana.log" start_stub read -r status elapsed < <(redeem recovered-order) [ "$status" = "404" ] || fail "recovered backend returned HTTP $status" -expect_error 9802 recovered-order || fail "unexpected recovery JSON" +expect_error 9802 recovered-order 404 || fail "unexpected recovery JSON" grep -q 'answered order .*recovered-order.* after 1 consecutive lookup' \ "$SCRATCH/paivana.log" || fail "recovery diagnostic missing from log" @@ -164,7 +182,7 @@ grep -q 'answered order .*recovered-order.* after 1 consecutive lookup' \ # genuine timeout and must retain the 504/code-11 response. read -r status elapsed < <(redeem timeout-order) [ "$status" = "504" ] || fail "elapsed deadline returned HTTP $status" -expect_error 11 timeout-order || fail "unexpected timeout JSON" +expect_error 11 timeout-order 0 || fail "unexpected timeout JSON" python3 - "$elapsed" <<'PY' || fail "timeout returned after only $elapsed seconds" import sys raise SystemExit(0 if float(sys.argv[1]) >= 4.5 else 1)