Skip to content

B is not idempotent for identical requests #100

Closed

Description

@civsiv

The Booking Specification states the following:
"note call to B is idempotent for the case where OrderItems match".

However this is not the case for two identical B requests. An OrderAlreadyExistsError error is returned.

An example of the B request sent is below:

{
    "@context": [
        "https://openactive.io/"
    ],
    "broker": {
        "id": "google",
        "name": "Google",
        "type": "Organization"
    },
    "brokerRole": "https://openactive.io/AgentBroker",
    "customer": {
        "email": "test@example.com",
        "familyName": "Doe",
        "givenName": "Jane",
        "id": "0",
        "telephone": "+1 800-789-7890",
        "type": "Person"
    },
    "orderedItem": [
        {
            "acceptedOffer": {
                "id": "https://iminoareferenceimplementation.azurewebsites.net/api/identifiers/session-series/209#/offers/0",
                "type": "Offer"
            },
            "orderedItem": {
                "id": "https://iminoareferenceimplementation.azurewebsites.net/api/identifiers/scheduled-sessions/209/events/2084",
                "type": "ScheduledSession"
            },
            "position": 0,
            "type": "OrderItem"
        }
    ],
    "seller": {
        "id": "https://iminoareferenceimplementation.azurewebsites.net/api/identifiers/sellers/1",
        "type": "Organization"
    },
    "totalPaymentDue": {
        "price": 0,
        "priceCurrency": "GBP",
        "type": "PriceSpecification"
    },
    "type": "Order"
}

Activity

  1. lukehesluke commented on May 6, 2021

    @lukehesluke
    Contributor

    I concur. Here is a test run which tests idempotency (and fails):

    free-opportunities_opportunity-free-without-checkpoints_OpenBookingSimpleFlow_FacilityUseSlot.md

    This is only for Simple Flow, btw. Approval Flow allows a repeated B:

    free-opportunities_opportunity-free-without-checkpoints_OpenBookingApprovalFlow_FacilityUseSlot.md

  2. nickevansuk commented on Jun 4, 2021

    @nickevansuk
    Contributor

    Noting this could be solved by storing the hash of a successful request at B, along with the successful response, and then reading that hash if an Order already exists to determine whether to return the same response, or an OrderAlreadyExistsError

    And that it also would need some kind of AsyncLock to avoid two Orders with the same uuid being processed at the same time

  3. nickevansuk commented on Jun 4, 2021

    @nickevansuk
    Contributor

    Suggested design:

    1. Could be an additional idempotency hook “GetIdempontentOrderResponse” added to the OrderStore, that simply resolves the response string based on a ClientID, UUID, and request hash, and returns null otherwise (a specific hook will make it clear to implementers they need to do something for this?).
    2. Then CreateOrder could return false instead of OrderAlreadyExistsError
    3. Then the "GetIdempontentOrderResponse" gets called by the StoreBookingEngine if CreateOrder returns false, and either responds with the response string or a OrderAlreadyExistsError
    4. Also a new hook “CompleteOrder” that is called after “UpdateOrder” (before the transaction completes), accepting the same params as UpdateOrder with the additional params of “serialisedResponseOrder” and “requestIdempotencyHash”. This ensures the Order is not serialised twice. (Note the Order can be mutated in UpdateOrder, and the OrderItemIds are added after CreateOrder - hence suggestion to add a new hook at the end of the flow)

    Also worth noting that idempotency could be implemented using MemoryCache in the OrderStore, or another similar method, rather than storing the hash/response in the Order table - as the response probably only needs to be cached for e.g. 15 minutes. Perhaps the ref impl should use MemoryCache rather than storing this in the DB in its example, as it’s also cleaner from a GDPR compliance perspective (personal data in the response is not stored for longer than is needed, and not duplicated)?

    Or perhaps the above hooks could be be virtual rather than abstract and include a default MemoryCache implementation? Would make for a cleaner upgrade path for existing implementers too. If we did this potentially the default implementation should also compress responses (e.g.) to optimise memory usage.

    The above hooks would support both…

    Also add an AsyncLock or similar to ensure that two requests for the same UUID are not processed in parallel

  4. matdavies commented on Jun 6, 2021

    @matdavies
    Contributor

    I started trying to implement per the above design suggestions, but came across an issue:

    StoreBookingEngine.ProcessFlowRequest (which is where I think you're suggesting we make modifications) returns TOrder, not a string (or ResponseContent). So part 3 above is not really possible in that method? Instead if GetOrder returns false, we'd need to deserialise the response into a TOrder again, and return that?

    So instead I looked at modifying ProcessOrderCreationB in CustomBookingEngine, but of course, CustomBookingEngine does not have access to the OrderStore, so it can't call the GetIdempotentOrderResponse method from there. Instead IBookingEngine would need that GetIdempotentOrderResponse method on it (which, in the StoreBookingEngine, would just pass off to the OrderStore).

    Let me know which you would prefer, or if I've got the wrong end of the stick.

    Also, the above aside, I think CreateOrder should return this:

        /// <summary>
        /// Result of creating (or attempting to create) an Order in a store
        /// </summary>
        public enum CreateOrderResult
        {
            OrderSuccessfullyCreated,
            OrderAlreadyExists
        }

    instead of a bool, as the above matches DeleteOrder and makes it easier to see what to return (as opposed to having to guess that you need to return false from CreateOrder if the order already exists - otherwise people could easily just continue to throw exceptions there which is what the reference impl does)

  5. nickevansuk commented on Jun 7, 2021

    @nickevansuk
    Contributor

    Yes sounds good - CreateOrderResult is much clearer!

    Can see the advantages of doing this at the CustomBookingEngine level, as for one implementation pattern we're really adding a caching hook.

    The tradeoff is that this is more prescriptively suggesting that a separate caching layer is used, rather than e.g. using the database to store it within the same transaction. An alternative could be that ProcessFlowRequest returns a string, and responsibility of OpenActiveSerializer.Serialize(response) is moved into the StoreBookingEngine?

  6. nickevansuk commented on Aug 3, 2023

    @nickevansuk
    Contributor

    After some thinking about the design, I've opted for doing this optionally at the CustomBookingEngine level (see #207), as it's cleaner and more closely follows the pattern of other libraries e.g. https://github.com/ikyriak/IdempotentAPI (which could be used if openactive/open-booking-api#239 is adopted in future).

    This does mean that it's not as easy to store a hash of the request in the database within CreateOrder, however it does result in a more maintainable solution overall.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions