From 17be569853a221e1fdd70a21b19a1a7bfbf40426 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone <exarkun@twistedmatrix.com> Date: Mon, 18 Nov 2019 11:54:16 -0500 Subject: [PATCH] represent the error state in the web interface too --- src/_zkapauthorizer/controller.py | 52 +++++++++++++++++-- src/_zkapauthorizer/model.py | 23 ++++++++ src/_zkapauthorizer/tests/strategies.py | 10 ++++ .../tests/test_client_resource.py | 27 ++++++++++ 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index fb3bd82..1939478 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -70,6 +70,7 @@ from .model import ( Pending as model_Pending, Unpaid as model_Unpaid, Redeeming as model_Redeeming, + Error as model_Error, ) @@ -179,6 +180,35 @@ class NonRedeemer(object): ) +@implementer(IRedeemer) +@attr.s(frozen=True) +class ErrorRedeemer(object): + """ + An ``ErrorRedeemer`` immediately locally fails voucher redemption with a + configured error. + """ + details = attr.ib(validator=attr.validators.instance_of(unicode)) + + @classmethod + def make(cls, section_name, node_config, announcement, reactor): + details = node_config.get_config( + section=section_name, + option=u"details", + ).decode("ascii") + return cls(details) + + def random_tokens_for_voucher(self, voucher, count): + return dummy_random_tokens(voucher, count) + + def redeem(self, voucher, random_tokens): + return fail(Exception(self.details)) + + def tokens_to_passes(self, message, unblinded_tokens): + raise Exception( + "Cannot be called because no unblinded tokens are ever returned." + ) + + @implementer(IRedeemer) @attr.s class DoubleSpendRedeemer(object): @@ -462,6 +492,9 @@ class PaymentController(object): which currently have redemption attempts in progress to timestamps when the attempt began. + :ivar dict[voucher, datetime] _error: A mapping from voucher identifiers + which have recently failed with an unrecognized, transient error. + :ivar dict[voucher, datetime] _unpaid: A mapping from voucher identifiers which have recently failed a redemption attempt due to an unpaid response from the redemption server to timestamps when the failure was @@ -472,6 +505,7 @@ class PaymentController(object): store = attr.ib() redeemer = attr.ib() + _error = attr.ib(default=attr.Factory(dict)) _unpaid = attr.ib(default=attr.Factory(dict)) _active = attr.ib(default=attr.Factory(dict)) @@ -534,11 +568,15 @@ class PaymentController(object): ) self._unpaid[voucher] = self.store.now() else: - self._log.failure( - "Redeeming random tokens for a voucher ({voucher}) failed.", - reason, + self._log.error( + "Redeeming random tokens for a voucher ({voucher}) failed: {reason}", + reason=reason, voucher=voucher, ) + self._error[voucher] = model_Error( + finished=self.store.now(), + details=reason.getErrorMessage().decode("utf-8", "replace"), + ) return None def _finalRedeemError(self, voucher, reason): @@ -563,6 +601,11 @@ class PaymentController(object): voucher, state=model_Unpaid(finished=self._unpaid[voucher.number]), ) + if voucher.number in self._error: + return attr.evolve( + voucher, + state=self._error[voucher.number], + ) return voucher @@ -581,6 +624,7 @@ _REDEEMERS = { u"dummy": DummyRedeemer.make, u"double-spend": DoubleSpendRedeemer.make, u"unpaid": UnpaidRedeemer.make, + u"error": ErrorRedeemer.make, u"ristretto": RistrettoRedeemer.make, } @@ -610,7 +654,7 @@ def bracket(first, last, between): except: info = exc_info() yield last() - raise info + raise info[0], info[1], info[2] else: yield last() returnValue(result) diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py index 4337dec..8af6d83 100644 --- a/src/_zkapauthorizer/model.py +++ b/src/_zkapauthorizer/model.py @@ -528,6 +528,24 @@ class Unpaid(object): } +@attr.s(frozen=True) +class Error(object): + """ + This is a non-persistent state in which a voucher exists when the database + state is **pending** but the most recent redemption attempt has failed due + to an error that is not handled by any other part of the system. + """ + finished = attr.ib(validator=attr.validators.instance_of(datetime)) + details = attr.ib(validator=attr.validators.instance_of(unicode)) + + def to_json_v1(self): + return { + u"name": u"error", + u"finished": self.finished.isoformat(), + u"details": self.details, + } + + @attr.s class Voucher(object): """ @@ -609,6 +627,11 @@ class Voucher(object): state = Unpaid( finished=parse_datetime(state_json[u"finished"]), ) + elif state_name == u"error": + state = Error( + finished=parse_datetime(state_json[u"finished"]), + details=state_json[u"details"], + ) else: raise ValueError("Unrecognized state {!r}".format(state_json)) diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py index 9d1ff65..223a7ec 100644 --- a/src/_zkapauthorizer/tests/strategies.py +++ b/src/_zkapauthorizer/tests/strategies.py @@ -216,6 +216,16 @@ def client_nonredeemer_configurations(): }) +def client_errorredeemer_configurations(details): + """ + Build ErrorRedeemer-using configuration values for the client-side plugin. + """ + return just({ + u"redeemer": u"error", + u"details": details, + }) + + def tahoe_configs(zkapauthz_v1_configuration=client_dummyredeemer_configurations()): """ Build complete Tahoe-LAFS configurations including the zkapauthorizer diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py index 7af34c1..22d2f54 100644 --- a/src/_zkapauthorizer/tests/test_client_resource.py +++ b/src/_zkapauthorizer/tests/test_client_resource.py @@ -116,6 +116,7 @@ from ..model import ( Redeemed, DoubleSpend, Unpaid, + Error, VoucherStore, memory_connect, ) @@ -129,6 +130,7 @@ from .strategies import ( client_doublespendredeemer_configurations, client_dummyredeemer_configurations, client_nonredeemer_configurations, + client_errorredeemer_configurations, vouchers, requests, ) @@ -136,6 +138,8 @@ from .matchers import ( Provides, ) +TRANSIENT_ERROR = u"something went wrong, who knows what" + # Helper to work-around https://github.com/twisted/treq/issues/161 def uncooperator(started=True): return Cooperator( @@ -687,6 +691,29 @@ class VoucherTests(TestCase): ), ) + @given(tahoe_configs(client_errorredeemer_configurations(TRANSIENT_ERROR)), datetimes(), vouchers()) + def test_get_known_voucher_error(self, get_config, now, voucher): + """ + When a voucher is first ``PUT`` and then later a ``GET`` is issued for the + same voucher then the response code is **OK** and details, including + those relevant to a voucher which has failed redemption due to any + kind of transient conditions, about the voucher are included in a + json-encoded response body. + """ + return self._test_get_known_voucher( + get_config, + now, + voucher, + MatchesStructure( + number=Equals(voucher), + created=Equals(now), + state=Equals(Error( + finished=now, + details=TRANSIENT_ERROR, + )), + ), + ) + def _test_get_known_voucher(self, get_config, now, voucher, voucher_matcher): """ Assert that a voucher that is ``PUT`` and then ``GET`` is represented in -- GitLab