rmosolgo / graphql-ruby

Ruby implementation of GraphQL
http://graphql-ruby.org
MIT License
5.38k stars 1.39k forks source link

Feature Request: Idempotent directive #3772

Closed joelzwarrington closed 2 years ago

joelzwarrington commented 2 years ago

Is your feature request related to a problem? Please describe.

As a GraphQL consumer, I'd like to safely retry an API request that might have failed due to connection issues, without causing duplication or conflicts.

Describe the solution you'd like

Idempotency is a common pattern in APIs where a consumer can retry API requests where subsequent attempts are ignored, but data could still be returned. It would be nice to have a directive built into the gem which supports this.

Something like:

mutation {
  updateSomething @indempotent(key: "guid") {
    result
  }
}

Describe alternatives you've considered

None, other than building it myself

Additional context

joelzwarrington commented 2 years ago

As well, looking into building it myself, there is an experimental warning, so curious if there are alternative approaches image

jqr commented 2 years ago

We've been using Relay's ClientMutationId to achieve idemptotency since mostly this revolves around just automatically adding an argument which the client makes up a value for... which is pretty much what ClientMutationId is. Then the server just checks the key before acting on the mutation.

Here's roughly what we're doing:

class Mutations::CreateTicket < Mutations::Base
  def resolve(ticket_type_id:, attributes:, client_mutation_id: nil, **_options)
    if used?(client_mutation_id)
      # response as if they did it, but it was already done
    else
      # actual mutation
    end
  end

  def used?(key)
    Ticket.where(idempotency_key: key).exists? if key
  end
end
class Types::Mutation < Types::BaseObject
  with_options extensions: [ClientMutationIdExtension] do |c|
    c.field :create_ticket, mutation: Mutations::CreateTicket
  end
end
# Similar to the default extension, but also passes the value through to the
# mutation so it can use it for deduplication.
class ClientMutationIdExtension < GraphQL::Schema::FieldExtension
  def apply
    field.argument :client_mutation_id, String, required: false
  end

  def resolve(object:, arguments:, **_rest)
    yield(object, arguments, arguments[:client_mutation_id])
  end

  def after_resolve(value:, memo:, **_rest)
    value.merge(client_mutation_id: memo)
  end
end
rmosolgo commented 2 years ago

Yep, my understanding is that Relay's clientMutationId was added for stuff like that. It looks like Shopify also uses an argument in the mutation, for example AppRevenueAttributionRecordInput.idempotencyKey.

What do you think of using an argument for this? (Why bother with a directive?)

rmosolgo commented 2 years ago

Let me know if you find a use case here that can't be covered by an argument!