diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index 21e0189b2e899564daebf3a07e9e6c3d27e5bbe6..429805b5bb222830b5b3cd0b65bebe508712b74a 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -30,6 +30,10 @@ from functools import ( from json import ( dumps, ) +from datetime import ( + timedelta, +) + import attr from zope.interface import ( @@ -37,6 +41,9 @@ from zope.interface import ( implementer, ) +from twisted.python.reflect import ( + namedAny, +) from twisted.logger import ( Logger, ) @@ -50,6 +57,9 @@ from twisted.internet.defer import ( inlineCallbacks, returnValue, ) +from twisted.internet.task import ( + LoopingCall, +) from twisted.web.client import ( Agent, ) @@ -73,6 +83,10 @@ from .model import ( Error as model_Error, ) +# The number of tokens to submit with a voucher redemption. +NUM_TOKENS = 100 + +RETRY_INTERVAL = timedelta(minutes=3) class AlreadySpent(Exception): """ @@ -492,14 +506,14 @@ class PaymentController(object): the voucher. The data store marks the voucher as redeemed and stores the unblinded tokens for use by the storage client. - :ivar dict[voucher, datetime] _active: A mapping from voucher identifiers + :ivar dict[unicode, datetime] _active: A mapping from voucher identifiers which currently have redemption attempts in progress to timestamps when the attempt began. - :ivar dict[voucher, datetime] _error: A mapping from voucher identifiers + :ivar dict[unicode, 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 + :ivar dict[unicode, 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 observed. @@ -509,38 +523,77 @@ class PaymentController(object): store = attr.ib() redeemer = attr.ib() + _clock = attr.ib( + default=attr.Factory(partial(namedAny, "twisted.internet.reactor")), + ) + _error = attr.ib(default=attr.Factory(dict)) _unpaid = attr.ib(default=attr.Factory(dict)) _active = attr.ib(default=attr.Factory(dict)) - def redeem(self, voucher, num_tokens=100): + def __attrs_post_init__(self): """ - :param unicode voucher: A voucher to redeem. + Check the voucher store for any vouchers in need of redemption. - :param int num_tokens: A number of tokens to redeem. + This is an initialization-time hook called by attrs. """ - # Pre-generate the random tokens to use when redeeming the voucher. - # These are persisted with the voucher so the redemption can be made - # idempotent. We don't want to lose the value if we fail after the - # server deems the voucher redeemed but before we persist the result. - # With a stable set of tokens, we can re-submit them and the server - # can re-sign them without fear of issuing excess passes. Whether the - # server signs a given set of random tokens once or many times, the - # number of passes that can be constructed is still only the size of - # the set of random tokens. - self._log.info("Generating random tokens for a voucher ({voucher}).", voucher=voucher) - tokens = self.redeemer.random_tokens_for_voucher(Voucher(voucher), num_tokens) + self._check_pending_vouchers() + # Also start a time-based polling loop to retry redemption of vouchers + # in retryable error states. + self._schedule_retries() + + def _schedule_retries(self): + # TODO: should not eagerly schedule calls. If there are no vouchers + # in an error state we shouldn't wake up at all. + # + # TODO: should schedule retries on a bounded exponential backoff + # instead, perhaps on a per-voucher basis. + self._retry_task = LoopingCall(self._retry_redemption) + self._retry_task.clock = self._clock + self._retry_task.start( + RETRY_INTERVAL.total_seconds(), + now=False, + ) - # Persist the voucher and tokens so they're available if we fail. - self._log.info("Persistenting random tokens for a voucher ({voucher}).", voucher=voucher) - self.store.add(voucher, tokens) + def _retry_redemption(self): + for voucher in self._error.keys() + self._unpaid.keys(): + if voucher in self._active: + continue + if self.get_voucher(voucher).state.should_start_redemption(): + self.redeem(voucher) + def _check_pending_vouchers(self): + """ + Find vouchers in the voucher store that need to be redeemed and try to + redeem them. + """ + vouchers = self.store.list() + for voucher in vouchers: + if voucher.state.should_start_redemption(): + self._log.info( + "Controller found voucher ({}) at startup that needs redemption.", + voucher=voucher.number, + ) + self.redeem(voucher.number) + else: + self._log.info( + "Controller found voucher ({}) at startup that does not need redemption.", + voucher=voucher.number, + ) + + def _perform_redeem(self, voucher, random_tokens): + """ + Use the redeemer to redeem the given voucher and random tokens. + + This will not persist the voucher or random tokens but it will persist + the result. + """ # Ask the redeemer to do the real task of redemption. self._log.info("Redeeming random tokens for a voucher ({voucher}).", voucher=voucher) d = bracket( lambda: setitem(self._active, voucher, self.store.now()), lambda: delitem(self._active, voucher), - lambda: self.redeemer.redeem(Voucher(voucher), tokens), + lambda: self.redeemer.redeem(Voucher(voucher), random_tokens), ) d.addCallbacks( partial(self._redeemSuccess, voucher), @@ -549,6 +602,43 @@ class PaymentController(object): d.addErrback(partial(self._finalRedeemError, voucher)) return d + def _get_random_tokens_for_voucher(self, voucher, num_tokens): + """ + Generate or load random tokens for a redemption attempt of a voucher. + """ + self._log.info("Generating random tokens for a voucher ({voucher}).", voucher=voucher) + tokens = self.redeemer.random_tokens_for_voucher(Voucher(voucher), num_tokens) + + self._log.info("Persistenting random tokens for a voucher ({voucher}).", voucher=voucher) + self.store.add(voucher, tokens) + + # XXX If the voucher is already in the store then the tokens passed to + # `add` are dropped and we need to get the tokens already persisted in + # the store. add should probably return them so we can use them. Or + # maybe we should pass the generator in so it can be called only if + # necessary. + return tokens + + def redeem(self, voucher, num_tokens=None): + """ + :param unicode voucher: A voucher to redeem. + + :param int num_tokens: A number of tokens to redeem. + """ + # Pre-generate the random tokens to use when redeeming the voucher. + # These are persisted with the voucher so the redemption can be made + # idempotent. We don't want to lose the value if we fail after the + # server deems the voucher redeemed but before we persist the result. + # With a stable set of tokens, we can re-submit them and the server + # can re-sign them without fear of issuing excess passes. Whether the + # server signs a given set of random tokens once or many times, the + # number of passes that can be constructed is still only the size of + # the set of random tokens. + if num_tokens is None: + num_tokens = NUM_TOKENS + tokens = self._get_random_tokens_for_voucher(voucher, num_tokens) + return self._perform_redeem(voucher, tokens) + def _redeemSuccess(self, voucher, unblinded_tokens): """ Update the database state to reflect that a voucher was redeemed and to @@ -587,6 +677,11 @@ class PaymentController(object): self._log.failure("Redeeming random tokens for a voucher ({voucher}) encountered error.", reason, voucher=voucher) return None + def get_voucher(self, number): + return self.incorporate_transient_state( + self.store.get(number), + ) + def incorporate_transient_state(self, voucher): """ Create a new ``Voucher`` which represents the given voucher but which also diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py index 8af6d83234719a7525b802a3c2d00173a6f55c48..65d653ddf94ceaa3435fbaf9a2bb690029f4de4b 100644 --- a/src/_zkapauthorizer/model.py +++ b/src/_zkapauthorizer/model.py @@ -466,6 +466,9 @@ class RandomToken(object): @attr.s(frozen=True) class Pending(object): + def should_start_redemption(self): + return True + def to_json_v1(self): return { u"name": u"pending", @@ -481,6 +484,9 @@ class Redeeming(object): """ started = attr.ib(validator=attr.validators.instance_of(datetime)) + def should_start_redemption(self): + return False + def to_json_v1(self): return { u"name": u"redeeming", @@ -493,6 +499,9 @@ class Redeemed(object): finished = attr.ib(validator=attr.validators.instance_of(datetime)) token_count = attr.ib(validator=attr.validators.instance_of((int, long))) + def should_start_redemption(self): + return False + def to_json_v1(self): return { u"name": u"redeemed", @@ -505,6 +514,9 @@ class Redeemed(object): class DoubleSpend(object): finished = attr.ib(validator=attr.validators.instance_of(datetime)) + def should_start_redemption(self): + return False + def to_json_v1(self): return { u"name": u"double-spend", @@ -521,6 +533,9 @@ class Unpaid(object): """ finished = attr.ib(validator=attr.validators.instance_of(datetime)) + def should_start_redemption(self): + return True + def to_json_v1(self): return { u"name": u"unpaid", @@ -538,6 +553,9 @@ class Error(object): finished = attr.ib(validator=attr.validators.instance_of(datetime)) details = attr.ib(validator=attr.validators.instance_of(unicode)) + def should_start_redemption(self): + return True + def to_json_v1(self): return { u"name": u"error", diff --git a/src/_zkapauthorizer/tests/fixtures.py b/src/_zkapauthorizer/tests/fixtures.py index dbb49e6568cae998024e71c866e67e40c88b089f..eb64887b6798e2d2b164e289bf095b5777276f66 100644 --- a/src/_zkapauthorizer/tests/fixtures.py +++ b/src/_zkapauthorizer/tests/fixtures.py @@ -20,6 +20,8 @@ from __future__ import ( absolute_import, ) +import attr + from fixtures import ( Fixture, TempDir, @@ -33,6 +35,11 @@ from allmydata.storage.server import ( StorageServer, ) +from ..model import ( + VoucherStore, + memory_connect, +) + class AnonymousStorageServer(Fixture): """ Supply an instance of allmydata.storage.server.StorageServer which @@ -50,3 +57,28 @@ class AnonymousStorageServer(Fixture): self.tempdir.asBytesMode().path, b"x" * 20, ) + + +@attr.s +class TemporaryVoucherStore(Fixture): + """ + Create a ``VoucherStore`` in a temporary directory associated with the + given test case. + + :ivar get_config: A function like the one built by ``tahoe_configs``. + :ivar get_now: A no-argument callable that returns a datetime giving a + time to consider as "now". + + :ivar store: A newly created temporary store. + """ + get_config = attr.ib() + get_now = attr.ib() + + def _setUp(self): + self.tempdir = self.useFixture(TempDir()) + self.config = self.get_config(self.tempdir.join(b"node"), b"tub.port") + self.store = VoucherStore.from_node_config( + self.config, + self.get_now, + memory_connect, + ) diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py index 86bcd8fa54d0ee95c13eefb6b748a8df3bd40f9b..672c95fa682d25dbe8ed90e484a2f6596ecc7d89 100644 --- a/src/_zkapauthorizer/tests/test_controller.py +++ b/src/_zkapauthorizer/tests/test_controller.py @@ -27,6 +27,10 @@ from json import ( from functools import ( partial, ) +from datetime import ( + datetime, + timedelta, +) from zope.interface import ( implementer, ) @@ -50,10 +54,6 @@ from testtools.twistedsupport import ( failed, ) -from fixtures import ( - TempDir, -) - from hypothesis import ( given, ) @@ -64,6 +64,9 @@ from hypothesis.strategies import ( from twisted.python.url import ( URL, ) +from twisted.internet.task import ( + Clock, +) from twisted.internet.defer import ( fail, ) @@ -97,6 +100,7 @@ from ..controller import ( NonRedeemer, DummyRedeemer, DoubleSpendRedeemer, + UnpaidRedeemer, RistrettoRedeemer, PaymentController, AlreadySpent, @@ -104,12 +108,11 @@ from ..controller import ( ) from ..model import ( - memory_connect, - VoucherStore, UnblindedToken, - Pending, - DoubleSpend, - Redeemed, + Pending as model_Pending, + DoubleSpend as model_DoubleSpend, + Redeemed as model_Redeemed, + Unpaid as model_Unpaid, ) from .strategies import ( @@ -120,6 +123,11 @@ from .strategies import ( from .matchers import ( Provides, ) +from .fixtures import ( + TemporaryVoucherStore, +) + +POSIX_EPOCH = datetime.utcfromtimestamp(0) class PaymentControllerTests(TestCase): """ @@ -131,15 +139,7 @@ class PaymentControllerTests(TestCase): A ``Voucher`` is not marked redeemed before ``IRedeemer.redeem`` completes. """ - tempdir = self.useFixture(TempDir()) - store = VoucherStore.from_node_config( - get_config( - tempdir.join(b"node"), - b"tub.port", - ), - now=lambda: now, - connect=memory_connect, - ) + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store controller = PaymentController( store, NonRedeemer(), @@ -149,7 +149,7 @@ class PaymentControllerTests(TestCase): persisted_voucher = store.get(voucher) self.assertThat( persisted_voucher.state, - Equals(Pending()), + Equals(model_Pending()), ) @given(tahoe_configs(), datetimes(), vouchers()) @@ -157,15 +157,7 @@ class PaymentControllerTests(TestCase): """ A ``Voucher`` is marked as redeemed after ``IRedeemer.redeem`` succeeds. """ - tempdir = self.useFixture(TempDir()) - store = VoucherStore.from_node_config( - get_config( - tempdir.join(b"node"), - b"tub.port", - ), - now=lambda: now, - connect=memory_connect, - ) + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store controller = PaymentController( store, DummyRedeemer(), @@ -175,7 +167,7 @@ class PaymentControllerTests(TestCase): persisted_voucher = store.get(voucher) self.assertThat( persisted_voucher.state, - Equals(Redeemed( + Equals(model_Redeemed( finished=now, token_count=100, )), @@ -187,15 +179,7 @@ class PaymentControllerTests(TestCase): A ``Voucher`` is marked as double-spent after ``IRedeemer.redeem`` fails with ``AlreadySpent``. """ - tempdir = self.useFixture(TempDir()) - store = VoucherStore.from_node_config( - get_config( - tempdir.join(b"node"), - b"tub.port", - ), - now=lambda: now, - connect=memory_connect, - ) + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store controller = PaymentController( store, DoubleSpendRedeemer(), @@ -206,12 +190,109 @@ class PaymentControllerTests(TestCase): self.assertThat( persisted_voucher, MatchesStructure( - state=Equals(DoubleSpend( + state=Equals(model_DoubleSpend( finished=now, )), ), ) + @given(tahoe_configs(), datetimes(), vouchers()) + def test_redeem_pending_on_startup(self, get_config, now, voucher): + """ + When ``PaymentController`` is created, any vouchers in the store in the + pending state are redeemed. + """ + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store + # Create the voucher state in the store with a redemption that will + # certainly fail. + unpaid_controller = PaymentController( + store, + UnpaidRedeemer(), + ) + unpaid_controller.redeem(voucher) + + # Make sure we got where we wanted. + self.assertThat( + unpaid_controller.get_voucher(voucher).state, + IsInstance(model_Unpaid), + ) + + # Create another controller with the same store. It will see the + # voucher state and attempt a redemption on its own. It has I/O as an + # `__init__` side-effect. :/ + success_controller = PaymentController( + store, + DummyRedeemer(), + ) + + self.assertThat( + success_controller.get_voucher(voucher).state, + IsInstance(model_Redeemed), + ) + + @given( + tahoe_configs(), + datetimes( + # I don't know that time-based parts of the system break down + # before the POSIX epoch but I don't know that they work, either. + # Don't time travel with this code. + min_value=POSIX_EPOCH, + # Once we get far enough into the future we lose the ability to + # represent a timestamp with microsecond precision in a floating + # point number, which we do with Clock. So don't go far enough + # into the future. + max_value=datetime(2200, 1, 1), + ), + vouchers(), + ) + def test_redeem_error_after_delay(self, get_config, now, voucher): + """ + When ``PaymentController`` receives a non-terminal error trying to redeem + a voucher, after some time passes it tries to redeem the voucher + again. + """ + clock = Clock() + clock.advance((now - POSIX_EPOCH).total_seconds()) + + store = self.useFixture( + TemporaryVoucherStore( + get_config, + lambda: datetime.utcfromtimestamp(clock.seconds()), + ), + ).store + controller = PaymentController( + store, + UnpaidRedeemer(), + clock, + ) + controller.redeem(voucher) + # It fails this time. + self.assertThat( + controller.get_voucher(voucher).state, + MatchesAll( + IsInstance(model_Unpaid), + MatchesStructure( + finished=Equals(now), + ), + ) + ) + + # Some time passes. + interval = timedelta(hours=1) + clock.advance(interval.total_seconds()) + + # It failed again. + self.assertThat( + controller.get_voucher(voucher).state, + MatchesAll( + IsInstance(model_Unpaid), + MatchesStructure( + # At the new time, demonstrating the retry was performed. + finished=Equals(now + interval), + ), + ), + ) + NOWHERE = URL.from_text(u"https://127.0.0.1/") diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py index 7414c67103fa6cd31c361c63a32947d8615e8984..d31e669a9f2fd108fa67e25aa655ef747707e246 100644 --- a/src/_zkapauthorizer/tests/test_model.py +++ b/src/_zkapauthorizer/tests/test_model.py @@ -79,6 +79,9 @@ from .strategies import ( random_tokens, unblinded_tokens, ) +from .fixtures import ( + TemporaryVoucherStore, +) class VoucherStoreTests(TestCase): @@ -107,7 +110,7 @@ class VoucherStoreTests(TestCase): ``VoucherStore.get`` raises ``KeyError`` when called with a voucher not previously added to the store. """ - store = store_for_test(self, get_config, lambda: now) + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store self.assertThat( lambda: store.get(voucher), raises(KeyError), @@ -119,7 +122,7 @@ class VoucherStoreTests(TestCase): ``VoucherStore.get`` returns a ``Voucher`` representing a voucher previously added to the store with ``VoucherStore.add``. """ - store = store_for_test(self, get_config, lambda: now) + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store store.add(voucher, tokens) self.assertThat( store.get(voucher), @@ -136,7 +139,7 @@ class VoucherStoreTests(TestCase): More than one call to ``VoucherStore.add`` with the same argument results in the same state as a single call. """ - store = store_for_test(self, get_config, lambda: now) + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store store.add(voucher, tokens) store.add(voucher, []) self.assertThat( @@ -155,7 +158,7 @@ class VoucherStoreTests(TestCase): ``VoucherStore.list`` returns a ``list`` containing a ``Voucher`` object for each voucher previously added. """ - store = store_for_test(self, get_config, lambda: now) + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store for voucher in vouchers: store.add(voucher, []) @@ -259,7 +262,7 @@ class UnblindedTokenStoreTests(TestCase): """ Unblinded tokens that are added to the store can later be retrieved. """ - store = store_for_test(self, get_config, lambda: now) + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store store.insert_unblinded_tokens_for_voucher(voucher_value, tokens) retrieved_tokens = store.extract_unblinded_tokens(len(tokens)) self.expectThat(tokens, AfterPreprocessing(sorted, Equals(retrieved_tokens))) @@ -297,7 +300,7 @@ class UnblindedTokenStoreTests(TestCase): ), ) - store = store_for_test(self, get_config, lambda: now) + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store store.add(voucher_value, random) store.insert_unblinded_tokens_for_voucher(voucher_value, unblinded) loaded_voucher = store.get(voucher_value) @@ -322,7 +325,7 @@ class UnblindedTokenStoreTests(TestCase): A voucher which is reported as double-spent is marked in the database as such. """ - store = store_for_test(self, get_config, lambda: now) + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store store.add(voucher_value, random_tokens) store.mark_voucher_double_spent(voucher_value) voucher = store.get(voucher_value) @@ -362,7 +365,7 @@ class UnblindedTokenStoreTests(TestCase): unique=True, ), ) - store = store_for_test(self, get_config, lambda: now) + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store store.add(voucher_value, random) store.insert_unblinded_tokens_for_voucher(voucher_value, unblinded) try: @@ -383,7 +386,7 @@ class UnblindedTokenStoreTests(TestCase): """ A voucher which is not known cannot be marked as double-spent. """ - store = store_for_test(self, get_config, lambda: now) + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store try: result = store.mark_voucher_double_spent(voucher_value) except ValueError: diff --git a/zkapauthorizer.nix b/zkapauthorizer.nix index 1a352e4285283753b614732062be8777706e1cf6..cfa93e1fdc7d5001281e8fcfd71d606d88981d0e 100644 --- a/zkapauthorizer.nix +++ b/zkapauthorizer.nix @@ -44,7 +44,6 @@ buildPythonPackage rec { checkPhase = '' runHook preCheck - set -x "${pyflakes}/bin/pyflakes" src/_zkapauthorizer ZKAPAUTHORIZER_HYPOTHESIS_PROFILE=${hypothesisProfile'} python -m ${if collectCoverage then "coverage run --branch --source _zkapauthorizer,twisted.plugins.zkapauthorizer --module"