APIスロットリングの実現方法

ある期間でのあるWeb APIに対するアクセス回数上限を与えたときのスロットリングについて、一例ではあるがRack::Attack (v6.3.1)で採用されているしくみについて調べた。

ここでは、ある期間において特定のクライアントからのアクセスを一定回数以下に制限することをスロットリングと呼ぶことにする*1。あるAPIにスロットリングをかけるときは、ある期間において経過した時間を計算しながら、その期間中にあるクライアントからエンドポイントに来たアクセスの回数をサーバサイドのキャッシュに記録する必要がある。

結論

スロットリングしたい期間をperiod(単位は秒)とする。

periodで与えられる期間だけ有効なカウンタ」を作ることができれば、そのカウンタにアクセス回数を記録することで、あるクライアントから上限を超える回数のアクセスが来ているかどうか検証できる。つまりスロットリングが実現できる。

カウンタの実現方法

特定期間において経過した時間を計算する

剰余を使ってタイマーを作ることで実現する。

まず、アクセスが来た時点でのUNIX時間t*2を取得する。このとき、tperiodで割ったときの剰余rを計算すると、当然のことながら0 <= r < periodがつねに成り立つ。つまり、1秒ごとに数値が増え、period - 1の次は0に戻るようなタイマーができる。Rubyで書くと次のようになる。

r = t % period # 1秒ごとに増加するタイマー。値は0からperiod - 1を繰り返す

そして、periodからrの値を引くと、1秒ごとにperiodから数値が減り、1の次は値がperiodに戻るようなタイマーができる。

(period - r).to_i # 1秒ごとに減少するタイマー。値はperiodから1を繰り返す

アクセス回数の記録時に、このタイマーから得られる値ををキャッシュの有効期限として設定することで、その期間における残り時間を表現できる。

なお、Rack::Attackでは実装上の理由でタイマーの最大値に1秒のバッファを持たせている*3

特定期間のアクセス回数を記録する

除算を使って、特定の期間だけ得られるキーを作り、そのキーを使ってキャッシュにアクセス回数を記録する。

先ほど取得したUNIX時間tperiodで割ったときの商qを仮に毎秒計算してみると、剰余が0 <= r < periodになる連続した範囲ごとにqの値が同じになる。

q = (t / period).to_i # periodの期間中はつねに同じ値

つまり、ある期間の間はqの値が同じになるので、アクセス元の情報とqの組み合わせをその期間のクライアントからのアクセス回数のキーとすると、あるクライアンのある期間におけるアクセス回数データを一意に特定できる。このキャッシュの有効期限を、先ほど計算した有効期限として設定すればよい。

以上より、periodで与えられる期間だけ有効なカウンタを作ることができる。このカウンタを利用してスロットリングを実現する。

参考

  • Rack::Attack 6.3.1
    • Rack::Attack::Throttle
      • matched_by?から呼び出しているcache.countが「periodで与えられる期間だけ有効なカウンタ」での計数を実現している
    • Rack::Attack::Cache
      • key_and_expirtyで時刻に基づいて経過時間とキャッシュのキーを計算し、do_countでカウンタを操作している

*1:Rack::Attackではthrottlingと呼んでいる

*2:RubyではTIme.now.to_iで取得する

*3:https://github.com/rack/rack-attack/pull/85

graphql-guardとポリシーオブジェクトをgraphql-ruby 1.8で使うための方法

これの続きです。

blog.kymmt.com

問題

graphql-guardはインラインでポリシーを書く方法の他に、ポリシーオブジェクトを定義して GraphQL::Guard.new に渡すという方法があります。使いかたはREADMEに書いてあるとおりです。

class GraphqlPolicy
  RULES = {
    Types::QueryType => {
      viewer: ->(obj, args, ctx) { !ctx[:current_user].nil? }
    }
  }

  def self.guard(type, field)
    type.introspection? ? ->(_, _, _) { true } : RULES.dig(type, field)
  end
end

MySchema = GraphQL::Schema.define do
  query Types::QueryType

  use GraphQL::Guard.new(policy_object: GraphqlPolicy)
end

Types::QueryType = GraphQL::ObjectType.define do
  name "Query"

  field :viewer, !Types::UserType do
    resolver ->(obj, args, ctx) { ctx[:current_user] }
  end
end

# 認証に失敗すると GraphQL::Guard::NotAuthorizedError がraiseされる

で、graphql-ruby 1.8でclass-based APIを使ってスキーマを書き直すと、残念ながらこのポリシーオブジェクトは動かなくなります。

class GraphqlPolicy
  RULES = {
    Types::Query => {
      viewer: ->(obj, args, ctx) { !ctx[:current_user].nil? }
    }
  }

  def self.guard(type, field)
    type.introspection? ? ->(_, _, _) { true } : RULES.dig(type.name, field)
  end
end

class MySchema < GraphQL::Schema
  query Types::Query

  use GraphQL::Guard.new(policy_object: GraphqlPolicy)
end

class Types::Query < Types::BaseObject
  field :viewer, Types::User, null: false

  def viewer
    context[:current_user]
  end
end

# 認証に失敗すると GraphQL::Guard::NotAuthorizedError がraiseされず、
# viewerのresolverの結果がnilになるので、次のエラーになってしまう
# {
#   "data": null,
#   "errors": [
#     {
#       "message": "Cannot return null for non-nullable field Query.viewer"
#     }
#   ]
# }

解決策

上の問題の原因はHash GraphqlPolicy::RULE のキーの等価比較にあります。

graphql-guardではフィールドのinstrumentationを使っており、GraphqlPolicy.guard に渡る type は、graphql-guardの差し込むinstrumentationで取得した、class-based API以前のオブジェクト型を表す GraphQL::ObjectType のインスタンスです。一方、RULE のキーはclass-based APIで導入された GraphQL::Schema::Object のインスタンスなので、クラスがそもそも一致していません。なので、これを GraphQL::ObjectType のような古いほうのクラスのインスタンスに変換するためのメソッド #to_graphql を呼ぶ必要があります。

また、GraphQL::ObjectType では eql? をオーバーライドしていないので、Hashのキーの比較にはオブジェクトIDが使われます。ここで、#to_graphql は内部で GraphQL::ObjectType.new しているので、これで作ったインスタンスと RULE のキーになっているインスタンスはオブジェクトIDが異なってしまいます。よって、キーが一致せず、RULE からguard Procを見つけられないので、望むような動作をしなくなっています。

GraphqlPolicy を次のように定義し直せば動きます。GraphQLの型をキーとするのは諦めて、その型の名前 name をキーとすることで、Hashのキー探索がうまくハマるようにしています。

class GraphqlPolicy
  RULES = {
    Types::Query.to_graphql.name => {
      viewer: ->(obj, args, ctx) { !ctx[:current_user].nil? }
    }
  }

  def self.guard(type, field)
    type.introspection? ? ->(_, _, _) { true } : RULES.dig(type.name, field)
  end
end

# 認証に失敗すると GraphQL::Guard::NotAuthorizedError がraiseされる

雑感

この件はgemのREADMEを改善したほうがよさそう。また、なにかもうちょっといい方法があれば教えてください。

graphql-guardをgraphql-ruby 1.8で使うためのある方法

問題

graphql-ruby 1.8ではclass-based APIが導入されました。フィールド定義は型を表すクラスのコンテキストでクラスメソッド field を呼ぶ形で定義します。

class Types::Query < Types::BaseObject
  field :viewer, Types::User, null: true

  def viewer
    context[:current_user]
  end
end

このときgraphql-guard(今回はv1.1.0)を次のように使おうとすると #<ArgumentError: unknown keyword: guard> になります。

class Types::Query < Types::BaseObject
  field :viewer, Types::User, guard ->(obj, args, ctx) { !ctx[:current_user].nil? }, null: true

  def viewer
    context[:current_user]
  end
end

class-based APIでフィールドを表すクラスが GraphQL::Field から GraphQL::Schema::Field にかわり、従来のように guard をキーとするProcを指定するだけでは、フィールドのメタデータに guard を設定することができなくなったためエラーになっています。

ある解決策

フィールドをカスタマイズして guard をメタデータとして持たせられるようにします。やりかたはこのドキュメントを参照。

GraphQL - Extending the GraphQL-Ruby Type Definition System

class Fields::Guardable < GraphQL::Schema::Field
  def initialize(*args, guard: nil, **kwargs, &block)
    @guard = guard
    super *args, **kwargs, &block
  end

  def to_graphql
    field_defn = super
    field_defn.tap { |d| d.metadata[:guard] = @guard }
  end
end

initialize はこのフィールドを定義するときに guard を設定できるようにしています。また、スキーマ定義が処理される過程で to_graphql が実行されるので、このときにフィールド定義を表す GraphQL::Schema のインスタンスが持つ属性 metadataguard を設定しています。

これで、class-based APIでもフィールド定義時に guard Procが設定できます。guard を定義できるフィールドにするために field_class を明示的に指定します。

class Types::Query < Types::BaseObject
  field_class Fields::Guardable

  field :viewer, Types::User, guard ->(obj, args, ctx) { !ctx[:current_user].nil? }, null: true

  def viewer
    context[:current_user]
  end
end

accepts_definitionを使う[2018-06-10 追記]

accepts_definition を利用すると1.8以前で使っていたオプションをclass-based APIで使うときにgraphql-ruby側でいい感じにハンドリングしてくれます。

GraphQL - Extending the GraphQL-Ruby Type Definition System

なので、Fields::Guardable に対して次のようにすればOKでした。

class Fields::Guardable < GraphQL::Schema::Field
  accepts_definition :guard
end

これを使えば従来どおり guard が使えます。

class Types::Query < Types::BaseObject
  field_class Fields::Guardable

  field :viewer, Types::User, null: true do
    guard ->(obj, args, ctx) { !ctx[:current_user].nil? }
  end

  def viewer
    context[:current_user]
  end
end

内部的には前節の説明に近いことをやっているようです。

移行時に補助的に使うメソッドのようにも見えるので、今後使い続けていいのかがちょっと微妙なところです。

雑感

  • Policyオブジェクトを使うときにどうやるか
  • Fields::Guardable のようなモジュールをgem側で提供できるようにしたほうがよさそうかも

Yokohama.rb Monthly Meetup #88 に参加した

2018-02-17(月)のYokohama.rb Monthly Meetup #88 参加メモです。

yokohamarb.doorkeeper.jp

Rubyレシピブック

気づいたら第11章「オブジェクトとメタプログラミング」に入っていました。

  • 273: オブジェクトが同じか調べる
  • 274: オブジェクトを複製する
  • 275: オブジェクトのクラスを調べる
  • 276: オブジェクトを文字列に変換する
  • 277: オブジェクトを数値に変換する

とくに盛り上がったのは274, 277あたりでしょうか。274ではObject#dupObject#cloneといったメソッドがshallow copyであるという話が紹介されていて、deep_copyのようなものはあったっけというところから、Active Supportのdeep_dupの実装を見ていました。ArrayやHashに対しては再帰的にdeep_dupを呼んでコピーしています。

rails/deep_dup.rb at v5.1.5 · rails/rails

277ではto_iが数値として有効でない文字列やnilを0に変換するのに対して、Kernel.#Integerは数値として有効でないデータに対しては例外を投げるという厳密なメソッドなので、後者を使うほうが安全な場面は結構あるのではという話をしていました。

Slack

Yokohama.rbのSlack workspaceができました。@hamaknさんにSlackinをセットアップしていただきました。



次回は2018-03-17です。

yokohamarb.doorkeeper.jp

dry-containerとdry-auto_injectでDIコンテナを作る

dry-rbファミリーのdry-containerdry-auto_injectを使うと、POROの組み合わせでDIが実現できます。

DIについてハイパーざっくり理解を得るには次の記事を読めばよいです。

qiita.com

上の記事の中のDIコンテナを適用したコードをdry-containerとdry-auto_injectを使って書き直してみました。

require 'dry-auto_inject'
require 'logger'

class FileLogger
  def initialize(filename)
    @logger = Logger.new(filename)
  end
end

class TwitterManager
  def initialize(logger)
    @logger = logger
  end
end

class DatabaseUserAuthenticator; end

class SampleContainer
  extend Dry::Container::Mixin

  register 'file_logger' do
    FileLogger.new('example.log')
  end

  register 'twitter_manager' do
    TwitterManager.new(FileLogger.new('twitter.log'))
  end

  register 'database_authenticator' do
    DatabaseUserAuthenticator.new
  end
end

Import = Dry::AutoInject(SampleContainer)

class Sample
  include Import['file_logger', 'twitter_manager']
end

これで、SampleSampleContainer で登録されているインスタンスを注入できました。次のようにメソッド形式の呼び出しでインスタンスが取得できます。

sample = Sample.new
pp sample.file_logger
pp sample.twitter_manager
pp sample.database_authenticator

pp で中身を確認するとインスタンスが取得できていることがわかります。また、database_authenticatorSample に注入しなかったので取得できずエラーになります。

#<FileLogger:0x00007f84780cb3c8
 @logger=
  #<Logger:0x00007f84780cb378
   @default_formatter=
    #<Logger::Formatter:0x00007f84780cb328 @datetime_format=nil>,
   @formatter=nil,
   @level=0,
   @logdev=
    #<Logger::LogDevice:0x00007f84780cb2d8
     @dev=#<File:example.log>,
     @filename="example.log",
     @mon_count=0,
     @mon_mutex=#<Thread::Mutex:0x00007f84780cb1c0>,
     @mon_owner=nil,
     @shift_age=0,
     @shift_period_suffix="%Y%m%d",
     @shift_size=1048576>,
   @progname=nil>>
#<TwitterManager:0x00007f84780cab08
 @logger=
  #<FileLogger:0x00007f84780cadd8
   @logger=
    #<Logger:0x00007f84780cadb0
     @default_formatter=
      #<Logger::Formatter:0x00007f84780cad60 @datetime_format=nil>,
     @formatter=nil,
     @level=0,
     @logdev=
      #<Logger::LogDevice:0x00007f84780cad10
       @dev=#<File:twitter.log>,
       @filename="twitter.log",
       @mon_count=0,
       @mon_mutex=#<Thread::Mutex:0x00007f84780cacc0>,
       @mon_owner=nil,
       @shift_age=0,
       @shift_period_suffix="%Y%m%d",
       @shift_size=1048576>,
     @progname=nil>>>
Traceback (most recent call last):
container.rb:43:in `<main>': undefined method `database_authenticator' for #<Sample:0x00007fd8149da578> (NoMethodError)

また、別のインスタンスに差し替えることもできます。テストのときはモックに差し替える、というような用途で便利です。

sample = Sample.new(file_logger: Logger.new(STDOUT))
pp sample.file_logger
#<Logger:0x00007f84788cfdd0
 @default_formatter=
  #<Logger::Formatter:0x00007f84788cfce0 @datetime_format=nil>,
 @formatter=nil,
 @level=0,
 @logdev=
  #<Logger::LogDevice:0x00007f84788cfbc8
   @dev=#<IO:<STDOUT>>,
   @filename=nil,
   @mon_count=0,
   @mon_mutex=#<Thread::Mutex:0x00007f84788cf010>,
   @mon_owner=nil,
   @shift_age=nil,
   @shift_period_suffix=nil,
   @shift_size=nil>,
 @progname=nil>