shep 0.1.0.pre.alpha0 → 0.2.0.pre.alpha0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -1
  3. data/Rakefile +1 -0
  4. data/doc/Shep/Entity/Account.html +22 -37
  5. data/doc/Shep/Entity/Context.html +8 -9
  6. data/doc/Shep/Entity/CustomEmoji.html +11 -15
  7. data/doc/Shep/Entity/MediaAttachment.html +13 -19
  8. data/doc/Shep/Entity/Notification.html +11 -15
  9. data/doc/Shep/Entity/Status.html +34 -61
  10. data/doc/Shep/Entity/StatusSource.html +9 -11
  11. data/doc/Shep/Entity/Status_Application.html +11 -10
  12. data/doc/Shep/Entity/Status_Mention.html +10 -13
  13. data/doc/Shep/Entity/Status_Tag.html +8 -9
  14. data/doc/Shep/Entity.html +156 -141
  15. data/doc/Shep/Error/Caller.html +4 -4
  16. data/doc/Shep/Error/Http.html +13 -13
  17. data/doc/Shep/Error/RateLimit.html +176 -0
  18. data/doc/Shep/Error/Remote.html +3 -4
  19. data/doc/Shep/Error/Server.html +5 -4
  20. data/doc/Shep/Error/Type.html +6 -8
  21. data/doc/Shep/Error.html +5 -5
  22. data/doc/Shep/Session.html +895 -570
  23. data/doc/Shep.html +20 -5
  24. data/doc/_index.html +10 -3
  25. data/doc/class_list.html +1 -1
  26. data/doc/file.README.html +52 -33
  27. data/doc/file_list.html +1 -1
  28. data/doc/index.html +117 -239
  29. data/doc/method_list.html +9 -1
  30. data/doc/top-level-namespace.html +2 -2
  31. data/lib/shep/entity_base.rb +6 -1
  32. data/lib/shep/exceptions.rb +5 -0
  33. data/lib/shep/session.rb +292 -146
  34. data/lib/shep/version.rb +4 -0
  35. data/lib/shep.rb +1 -1
  36. data/run_rake_test.example.sh +12 -5
  37. data/shep.gemspec +4 -2
  38. data/spec/session_reader_1_unauth_spec.rb +20 -17
  39. data/spec/session_reader_2_auth_spec.rb +17 -19
  40. data/spec/session_writer_spec.rb +4 -11
  41. data/spec/session_zzz_tricky_spec.rb +136 -0
  42. data/spec/spec_helper.rb +12 -3
  43. metadata +12 -9
data/lib/shep/session.rb CHANGED
@@ -11,8 +11,9 @@ module Shep
11
11
 
12
12
  # Represents a connection to a Mastodon (or equivalent) server.
13
13
  #
14
- # @attr_reader [Logger] logger The logger object
15
- # @attr_reader [String] host The Server's hostname
14
+ # @attr_reader [Logger] logger The logger object
15
+ # @attr_reader [String] host The Server's hostname
16
+ # @attr_reader [String] user_agent User-Agent string; frozen
16
17
  #
17
18
  # ## Conventions
18
19
  #
@@ -24,29 +25,97 @@ module Shep
24
25
  # each item in turn and the block's result is ignored. Otherwise,
25
26
  # it returns an `Enumerator` which can be used in the usual ways.
26
27
  #
27
- # `each_*` will automatically paginate through the available items;
28
- # for example,
28
+ # Some examples:
29
29
  #
30
- # statuses = session.each_public_status.to_a
30
+ # # Evaluate a block on each status
31
+ # session.each_status(account) { |status| do_thing(status) }
31
32
  #
32
- # will retrieve the server's entire public timeline and put it in an
33
- # array. (Note: don't do this.) The `limit:` keyword option will
34
- # set an upper limit on the number of items retrieved (but note that
35
- # the method may retrieve more from the API endpoint).
33
+ # # Retrieve the last 100 statuses in an array
34
+ # statuses = session.each_status(account, limit: 100).to_a
35
+ #
36
+ # # Retrieve the last 200 statuses via an enumerator and do
37
+ # # extra transformation on the result before collecting
38
+ # # them in an array.
39
+ # statuses = session.each_status(account, limit: 200)
40
+ # .select{|status| BESTIES.include? status.account.username }
41
+ # .map{|status| status.id}
42
+ # .to_a
43
+ #
44
+ # The actual web API "paginates" the output. That is, it returns
45
+ # the first 40 (or so) items and then provides a link to the next
46
+ # chunk. Shep's `each_*` methods handle this for you automatically.
47
+ # This means that unless you use `limit:`, the `each_*` methods will
48
+ # retrieve **all** available items, at least until you reach the
49
+ # rate limit (see below).
50
+ #
51
+ # Note that it is safe to leave an `each_*` methods block with
52
+ # `break`, `return`, an exception, or any other such mechanism.
36
53
  #
37
54
  # The remaining Mastodon API methods will in some way modify the
38
55
  # state of the server and return an Entity subinstance on success.
39
56
  #
40
57
  # All API calls throw an exception on failure.
58
+ #
59
+ # ## Rate Limits
60
+ #
61
+ # Mastodon servers restrict the number of times you can use a
62
+ # specific endpoint within a time period as a way to prevent abuse.
63
+ # Shep provides several tools for handling these limits gracefully.
64
+ #
65
+ # 1. The method {rate_limit} will return a Struct that tells you how
66
+ # many requests you have left and when the count is reset.
67
+ #
68
+ # 2. If a rate limit is exceeded, the method will throw an
69
+ # {Error::RateLimit} exception instead of an ordinary
70
+ # {Error::Http} exception.
71
+ #
72
+ # 3. If the Session is created with argument `rate_limit_retry:` set
73
+ # to true, the Session will instead wait out the reset time and
74
+ # try again.
75
+ #
76
+ # If you enable the wait-and-retry mechanism, you can also provide a
77
+ # hook function (i.e. a thing that responds to `call`) via
78
+ # constructor argument `retry_hook:`. This is called with one
79
+ # argument, the result of {rate_limit} for the limited API endpoint,
80
+ # immediately before Shep starts waiting for the limit to reset.
81
+ #
82
+ # The built-in wait time takes the callback's execution time into
83
+ # account so it's possible to use the callback to do your own
84
+ # waiting and use that time more productively.
85
+ #
86
+ # Alternately, all of the `each_*` methods have a `limit:` parameter
87
+ # so it's easy to avoid making too many API calls and many have a
88
+ # `max_id:` parameter that allows you to continue where you left off.
89
+ #
41
90
  class Session
42
- attr_reader :logger, :host
91
+ attr_reader :logger, :host, :user_agent
43
92
 
44
93
  # Initialize a new {Session}.
45
94
  #
46
- # @param host [String] Hostname of the server
47
- # @param token [String] Bearer token; optional
48
- # @param logger [Logger] The logger or mode; optional
49
- # @param debug_http [Boolean] Enable `Net::HTTP` debugging; insecure!
95
+ # @param host [String] Hostname of the server
96
+ # @param token [String] Bearer token; optional
97
+ #
98
+ # @param user_agent [String] User-Agent string to use
99
+ # @param ua_comment [String] Comment part of User-Agent string
100
+ #
101
+ # @param rate_limit_retry [Boolean] Handle request limits by waiting
102
+ # for the count to reset and trying
103
+ # again.
104
+ #
105
+ # @param retry_hook [Proc] One-argument hook function to call
106
+ # before waiting for the rate limit
107
+ # to reset
108
+ #
109
+ # @param logger [Logger] The logger or mode; optional
110
+ # @param debug_http [Boolean] Enable `Net::HTTP` debugging;
111
+ # **insecure!**
112
+ #
113
+ # By default, the User-Agent header is set to the gem's
114
+ # identifier, but may be overridden with the `user_agent`
115
+ # parameter. It is your responsibility to make sure it is
116
+ # formatted correctly. You can also append comment text to the
117
+ # given User-Agent string with `ua_comment`; this lets you add a
118
+ # comment to the default text.
50
119
  #
51
120
  # Parameter `logger` may be a `Logger` object, `nil`, or a
52
121
  # `Symbol` whose value is the name of one of the supported log
@@ -56,17 +125,37 @@ module Shep
56
125
  #
57
126
  # If `debug_http` is true, compression is disabled and the
58
127
  # transactions are sent to `STDERR` via
59
- # `Net::HTTP.set_debug_output`. **WARNING:** this opens a serious
60
- # security hole and should not be used in production.
128
+ # `Net::HTTP.set_debug_output`.
129
+ #
130
+ # **WARNING:** this opens a serious security hole and should not
131
+ # be used in production.
61
132
  def initialize(host:,
62
- token: nil,
63
- logger: nil,
64
- debug_http: false)
133
+ token: nil,
134
+ user_agent: "ShepRubyGem/#{Shep::Version}",
135
+ ua_comment: nil,
136
+
137
+ rate_limit_retry: false,
138
+ retry_hook: nil,
139
+
140
+ logger: nil,
141
+ debug_http: false)
65
142
  @host = host
66
143
  @token = token
67
144
  @logger = init_logger(logger)
68
- @rate_limit = Struct.new(:limit, :remaining, :reset).new
145
+
146
+ @user_agent = user_agent
147
+ @user_agent += " #{ua_comment}" if ua_comment
148
+ @user_agent.freeze
149
+
150
+ @rate_limit_retry = rate_limit_retry
151
+ @retry_hook = retry_hook
152
+
69
153
  @debug_http = debug_http
154
+
155
+ @rate_limit = Struct.new(:limit, :remaining, :reset).new
156
+
157
+ raise Error::Caller.new("retry_hook: must a callable or nil") unless
158
+ @retry_hook == nil || @retry_hook.respond_to?(:call)
70
159
  end
71
160
 
72
161
  private
@@ -88,7 +177,7 @@ module Shep
88
177
 
89
178
  public
90
179
 
91
- # Return the rate limit information as of the last operation.
180
+ # Return the rate limit information from the last REST request.
92
181
  #
93
182
  # The result is a Struct with the following fields:
94
183
  #
@@ -101,6 +190,10 @@ module Shep
101
190
  # times within 5 minutes but no more than 30 media uploads are
102
191
  # allowed within a 30 minute time period.
103
192
  #
193
+ # Note also that some Shep methods will perform multiple API
194
+ # requests; this is only ever the rate limit information from the
195
+ # latest of these.
196
+ #
104
197
  # @see https://docs.joinmastodon.org/api/rate-limits/
105
198
  #
106
199
  # @return [Struct.new(:limit, :remaining, :reset)]
@@ -150,14 +243,19 @@ module Shep
150
243
  #
151
244
  # The username must belong to a user on the current server.
152
245
  #
153
- # @param handle [String] the account's username **with** the
246
+ # @param handle [String] the account's username with or without the
154
247
  # leading '@' character (e.g. @benoitmandelbot)
155
248
  #
156
- # @return [Entity::Account]
249
+ #
250
+ # @return [Entity::Account, nil] The Account or nil if it can't be found.
157
251
  #
158
252
  # @see https://docs.joinmastodon.org/methods/accounts/#get
159
253
  def fetch_account_by_username(handle)
160
254
  return rest_get("accounts/lookup", Entity::Account, {acct: handle})
255
+ rescue Error::Http => oopsie
256
+ # As a special case, return nil if the lookup fails
257
+ return nil if oopsie.response.is_a?(Net::HTTPNotFound)
258
+ raise oopsie
161
259
  end
162
260
 
163
261
  # Fetch an individual notification by ID.
@@ -233,18 +331,18 @@ module Shep
233
331
 
234
332
  if !refetch && File.exist?(outfile)
235
333
  @logger.info "Found '#{outfile}'; skipping."
236
- success = true
237
334
  else
238
335
  tmp = File.join(media_dir, SecureRandom.uuid + '.tmp')
239
- success = basic_get_binary(ma.url, tmp)
240
- if success
336
+ begin
337
+ basic_get_binary(ma.url, tmp)
241
338
  FileUtils.mv(tmp, outfile)
242
- else
339
+ rescue Error::Http => e
243
340
  FileUtils.rm(tmp, force: true)
341
+ raise e
244
342
  end
245
343
  end
246
344
 
247
- media[ma.url.to_s] = success ? outfile : nil
345
+ media[ma.url.to_s] = outfile
248
346
  }
249
347
 
250
348
  return [status, media]
@@ -300,6 +398,8 @@ module Shep
300
398
  # @param limit [Integer] Maximum number of accounts to
301
399
  # retrieve
302
400
  #
401
+ # @param max_id [String] retrieve results older than this ID.
402
+ #
303
403
  # @param only_media [Boolean] If true, filter for statuses
304
404
  # with media
305
405
  #
@@ -321,6 +421,7 @@ module Shep
321
421
  # @see https://docs.joinmastodon.org/methods/accounts/#statuses
322
422
  def each_status(account_id,
323
423
  limit: nil,
424
+ max_id: "",
324
425
  only_media: false,
325
426
  exclude_replies: false,
326
427
  exclude_reblogs: false,
@@ -337,6 +438,8 @@ module Shep
337
438
  #
338
439
  # @param limit [Integer] Max. items to retrieve.
339
440
  #
441
+ # @param max_id [String] retrieve results older than this ID.
442
+ #
340
443
  # @param local [Boolean] Retrieve only local statuses
341
444
  #
342
445
  # @param remote [Boolean] Retrieve only remote statuses
@@ -351,6 +454,7 @@ module Shep
351
454
  #
352
455
  # @see https://docs.joinmastodon.org/methods/timelines/#public
353
456
  def each_public_status(limit: nil,
457
+ max_id: "",
354
458
  local: false,
355
459
  remote: false,
356
460
  only_media: false,
@@ -373,6 +477,8 @@ module Shep
373
477
  #
374
478
  # @param limit [Integer] maximum number of items to retrieve
375
479
  #
480
+ # @param max_id [String] return results older than this ID.
481
+ #
376
482
  # @param local [Boolean] retrieve only local statuses
377
483
  #
378
484
  # @param remote [Boolean] retrieve only remote statuses
@@ -392,6 +498,7 @@ module Shep
392
498
  # @see https://docs.joinmastodon.org/methods/timelines/#tag
393
499
  def each_tag_status(hashtag_s,
394
500
  limit: nil,
501
+ max_id: "",
395
502
  local: false,
396
503
  remote: false,
397
504
  only_media: false,
@@ -422,10 +529,16 @@ module Shep
422
529
  #
423
530
  # Requires token.
424
531
  #
425
- # @param limit [Integer] ,
426
- # @param local [Boolean] ,
427
- # @param remote [Boolean] ,
428
- # @param only_media [Boolean] ,
532
+ # @param limit [Integer] maximum number of items to retrieve
533
+ #
534
+ # @param max_id [String] retrieve results older than this ID.
535
+ #
536
+ # @param local [Boolean] retrieve only local statuses
537
+ #
538
+ # @param remote [Boolean] retrieve only remote statuses
539
+ #
540
+ # @param only_media [Boolean] retrieve only media status
541
+ #
429
542
  #
430
543
  # @yield [item]
431
544
  #
@@ -437,6 +550,7 @@ module Shep
437
550
  # @see https://docs.joinmastodon.org/methods/timelines/#home
438
551
  def each_home_status(limit: nil,
439
552
  local: false,
553
+ max_id: "",
440
554
  remote: false,
441
555
  only_media: false,
442
556
  &block)
@@ -488,6 +602,18 @@ module Shep
488
602
 
489
603
  # Retrieve each notification.
490
604
  #
605
+ # Requires a bearer token.
606
+ #
607
+ # Notification types are indicated by of the following symbols:
608
+ #
609
+ # `:mention`, `:status`, `:reblog`, `:follow`, `:follow_request`
610
+ # `:favourite`, `:poll`, `:update`, `:admin.sign_up`, or
611
+ # `:admin.report`
612
+ #
613
+ # This method will throw an `Error::Caller` exception if an
614
+ # unknown value is used.
615
+ #
616
+ #
491
617
  # @param types [Array<String>] list of notifications types to
492
618
  # enumerate; others are ignoredn
493
619
  #
@@ -514,9 +640,13 @@ module Shep
514
640
  favourite poll update admin.sign_up
515
641
  admin.report}
516
642
 
517
- # Ensure valid filter values
518
- types.uniq!
519
- exclude_types.uniq!
643
+ # Remove duplicates and convert strings to symbols
644
+ [types, exclude_types].each{|param|
645
+ param.map!{|item| item.intern}
646
+ param.uniq!
647
+ }
648
+
649
+ # Now, ensure there are no incorrect notification types.
520
650
  (types + exclude_types).each{|filter|
521
651
  assert("Unknown notification type: #{filter}") {
522
652
  allowed_notifications.include?(filter.intern)
@@ -550,6 +680,8 @@ module Shep
550
680
  #
551
681
  # @param language [String] ISO language code
552
682
  #
683
+ # @return [Entity::Status] The new status.
684
+ #
553
685
  # @see https://docs.joinmastodon.org/methods/statuses/#create
554
686
  def post_status(text,
555
687
  visibility: :private,
@@ -669,12 +801,14 @@ module Shep
669
801
  return nil
670
802
  end
671
803
 
672
- private
804
+
673
805
 
674
806
  #
675
- # High-level REST support
807
+ # High(ish)-level REST support
676
808
  #
677
809
 
810
+ private
811
+
678
812
  # Extract all of the keyword arguments and their values from the
679
813
  # caller's context. (The context is 'cbinding', which must be a
680
814
  # call to method 'cmethod'.)
@@ -711,7 +845,7 @@ module Shep
711
845
 
712
846
  def rest_delete(path, result_klass)
713
847
  uri = rest_uri(path, {})
714
- result_obj, _ = basic_rest_get_or_delete(uri, also_delete: true)
848
+ result_obj, _ = basic_rest_get_or_delete(uri, is_delete: true)
715
849
  return result_klass.from(result_obj)
716
850
  end
717
851
 
@@ -766,7 +900,7 @@ module Shep
766
900
 
767
901
 
768
902
  #
769
- # Low-level REST support
903
+ # Low(ish)-level REST support
770
904
  #
771
905
 
772
906
  # Given a hash of query arguments, return an array of key-value
@@ -804,51 +938,91 @@ module Shep
804
938
  return URI("https://#{@host}/api/#{version}/#{path}#{args}")
805
939
  end
806
940
 
807
- def basic_rest_get_or_delete(uri, also_delete: false)
941
+ def parse_link_header(hdr)
942
+ result = {}
943
+ return result unless hdr # could be nil
944
+
945
+ for link in hdr.split(', ')
946
+ md = link.match(/^<([^>]+)>; rel="([^"]+)"/)
947
+ assert{md}
948
+
949
+ result[ md[2] ] = md[1]
950
+ end
951
+
952
+ return result
953
+ end
954
+
955
+ # Perform a GET or DELETE operation. (They have mostly the same
956
+ # structure, so we combine the functionality here.)
957
+ def basic_rest_get_or_delete(url, is_delete: false)
808
958
  headers = headers_for(:get)
809
959
 
810
- # Do the thing.
811
- response = http_get_or_delete(uri, headers, also_delete)
812
- update_rate_limit(response)
960
+ request = is_delete ?
961
+ Net::HTTP::Delete.new(url, headers) :
962
+ Net::HTTP::Get.new(url, headers)
813
963
 
814
- result = response.body() if response.class.body_permitted?
815
- raise Error::Http.new(response) if response.is_a? Net::HTTPClientError
964
+ response = http_operation(request)
816
965
 
817
- result_obj = JSON.parse(result)
966
+ result = JSON.parse(response.body)
818
967
 
819
- if result.is_a?(Hash) && result_obj.has_key?("error")
820
- raise Error::Server.new(result_obj["error"])
968
+ if result.is_a?(Hash) && result.has_key?("error")
969
+ raise Error::Server.new(result["error"])
821
970
  end
822
971
 
823
972
  link = parse_link_header(response["link"])
824
973
 
825
- return [result_obj, link]
974
+ return [result, link]
826
975
  rescue JSON::JSONError => e
827
976
  raise Error::Remote.new("Error parsing result JSON: #{e}")
828
977
  end
829
978
 
830
- def http_get_or_delete(uri, headers, is_delete)
831
- @logger.debug("#{is_delete ? "Deleting" : "Requesting"} #{uri} " +
832
- "(token: #{!!@token})")
979
+ # Retrieve the resource (assumed to be binary) at 'uri' and write
980
+ # it to 'filename'. For now, returns false if the request
981
+ # returned an error code and true on success.
982
+ def basic_get_binary(url, filename)
983
+ request = Net::HTTP::Get.new(url, headers_for(:get))
833
984
 
834
- response = Net::HTTP.start(
835
- uri.hostname,
836
- uri.port,
837
- use_ssl: uri.scheme == 'https'
838
- ) { |http|
839
- next is_delete ?
840
- http.delete(uri.path, headers) :
841
- http.request_get(uri, headers)
842
- }
985
+ @logger.debug("Output file is #{filename}")
986
+ File.open(filename, "wb") { |outfile| http_operation(request, outfile) }
987
+ @logger.debug("Done (#{filename})")
988
+ end
843
989
 
844
- @logger.debug("Response: #{response}")
845
- return response
990
+ def basic_rest_post_or_put(url, formdata, is_put: false)
991
+ headers = headers_for(:post)
992
+
993
+ # Select request type
994
+ request = is_put ?
995
+ Net::HTTP::Put.new(url, headers) :
996
+ Net::HTTP::Post.new(url, headers)
997
+
998
+ # Set the parameters
999
+ enctype = 'multipart/form-data' if formdata.is_a?(Array)
1000
+ enctype = 'application/x-www-form-urlencoded' if
1001
+ formdata.is_a?(Hash)
1002
+ enctype or
1003
+ raise Error::Caller.new("Unknown formdate type: #{formdata.class}")
1004
+ request.set_form(formdata, enctype)
1005
+
1006
+ # Do the deed
1007
+ response = http_operation(request)
1008
+
1009
+ result = JSON.parse(response.body)
1010
+
1011
+ if result.is_a?(Hash) && result.has_key?("error")
1012
+ raise Error::Server.new(result["error"])
1013
+ end
1014
+
1015
+ return result
1016
+ rescue JSON::JSONError => e
1017
+ raise Error::Remote.new("Error parsing result JSON: #{e}")
846
1018
  end
847
1019
 
848
1020
  def headers_for(method)
849
1021
  headers = {}
850
1022
  headers["Authorization"] = "Bearer #{@token}" if @token
851
1023
 
1024
+ headers['User-Agent'] = @user_agent
1025
+
852
1026
  if method == :post
853
1027
  extras = {
854
1028
  "Indempotency-Key": SecureRandom.uuid,
@@ -860,111 +1034,83 @@ module Shep
860
1034
  return headers
861
1035
  end
862
1036
 
863
- def update_rate_limit(response)
864
- @rate_limit.limit = @rate_limit.remaining = @rate_limit.reset = nil
1037
+ def http_operation(request, output_handle = nil)
1038
+ url = request.uri
865
1039
 
866
- @rate_limit.limit = response['X-RateLimit-Limit'].to_i
867
- @rate_limit.remaining = response['X-RateLimit-Remaining'].to_i
1040
+ while true
1041
+ http = Net::HTTP.new(url.hostname, url.port)
1042
+ http.use_ssl = (url.scheme == 'https')
868
1043
 
869
- reset = response['X-RateLimit-Reset']
870
- @rate_limit.reset = Time.iso8601(reset) if reset
1044
+ if @debug_http
1045
+ http.set_debug_output(STDERR)
1046
+ request['Accept-Encoding'] = 'identity'
1047
+ end
871
1048
 
872
- @logger.debug "Rate limit: #{rate_limit_desc}"
873
- end
1049
+ http.start do |http|
1050
+ @logger.debug("Request #{request}; (token: #{!!@token})")
874
1051
 
875
- # Retrieve the resource (assumed to be binary) at 'uri' and write
876
- # it to 'filename'. For now, returns false if the request
877
- # returned an error code and true on success.
878
- def basic_get_binary(uri, filename)
879
- headers = headers_for(:get)
1052
+ # We have to invoke 'request' with a block argument to get the
1053
+ # response because that's the only way we can get at it before
1054
+ # it's downloaded the entire response into RAM.
1055
+ http.request(request) do |response|
1056
+ @logger.debug("Response: #{response}")
880
1057
 
881
- @logger.debug("Requesting #{uri} (token: #{!!@token})")
882
- Net::HTTP.get_response(uri, headers) {|response|
883
- @logger.debug("Response: #{response.code}")
1058
+ update_rate_limit(response)
884
1059
 
885
- update_rate_limit(response)
1060
+ #raise_http_exception_if_error(response)
886
1061
 
887
- if response.is_a? Net::HTTPClientError
888
- @logger.warn("Got response #{response} for #{uri}.")
889
- return false
890
- end
1062
+ if response.is_a?(Net::HTTPClientError)
1063
+ # Finish any body reading that may have been in
1064
+ # progress.
1065
+ response.read_body()
891
1066
 
892
- @logger.debug("Writing body to #{filename}")
893
- File.open(filename, "wb") { |outfile|
894
- response.read_body { |chunk|
895
- @logger.debug(" Writing #{chunk.size} bytes...")
896
- outfile.write(chunk)
897
- }
898
- }
899
- @logger.debug("Done (#{filename})")
900
- }
1067
+ # Special case: too many requests. We may throw an
1068
+ # exception or wait until it resets and try again.
1069
+ if response.is_a?(Net::HTTPTooManyRequests)
1070
+ handle_rate_limit_reached(response)
1071
+ next # if we get here, we're trying again
1072
+ end
901
1073
 
902
- return true
903
- end
1074
+ raise Error::Http.new(response)
1075
+ end
904
1076
 
905
- def parse_link_header(hdr)
906
- result = {}
907
- return result unless hdr # could be nil
1077
+ # read_body will write the response body to output_handle if
1078
+ # it's a handle or keep it internally if it's nil.
1079
+ response.read_body(output_handle)
908
1080
 
909
- for link in hdr.split(', ')
910
- md = link.match(/^<([^>]+)>; rel="([^"]+)"/)
911
- assert{md}
912
-
913
- result[ md[2] ] = md[1]
1081
+ return response
1082
+ end
1083
+ end
914
1084
  end
915
-
916
- return result
917
1085
  end
918
1086
 
1087
+ def handle_rate_limit_reached(response)
1088
+ raise Error::RateLimit.new(response) unless @rate_limit_retry
919
1089
 
920
- def basic_rest_post_or_put(uri, formdata, is_put: false)
921
- headers = headers_for(:post)
922
-
923
- # Do the thing.
924
- @logger.debug("Posting #{uri} (token: #{!!@token})")
925
- response = http_post_form(uri, headers, formdata, is_put)
926
- @logger.debug("Response: #{response}")
927
-
928
- update_rate_limit(response)
1090
+ # Call the retry hook first.
1091
+ @retry_hook.call(self.rate_limit().dup) if @retry_hook
929
1092
 
930
- result = response.body() if response.class.body_permitted?
931
- raise Error::Http.new(response) if response.is_a? Net::HTTPClientError
932
-
933
- result_obj = JSON.parse(result)
934
-
935
- if result.is_a?(Hash) && result_obj.has_key?("error")
936
- raise Error::Server.new(result_obj["error"])
1093
+ # Now, wait out any remaining elapsed time.
1094
+ while true
1095
+ delay = (@rate_limit.reset - Time.now) + 2
1096
+ break if delay <= 0
1097
+ @logger.info "Sleeping for #{delay.round} seconds."
1098
+ sleep delay
937
1099
  end
938
-
939
- return result_obj
940
- rescue JSON::JSONError => e
941
- raise Error::Remote.new("Error parsing result JSON: #{e}")
942
1100
  end
943
1101
 
944
- def http_post_form(url, headers, formdata, is_put)
945
- request = is_put ?
946
- Net::HTTP::Put.new(url, headers) :
947
- Net::HTTP::Post.new(url, headers)
948
-
949
- enctype = 'multipart/form-data' if formdata.is_a?(Array)
950
- enctype = 'application/x-www-form-urlencoded' if
951
- formdata.is_a?(Hash)
952
- enctype or
953
- raise Error::Caller.new("Unknown formdate type: #{formdata.class}")
954
- request.set_form(formdata, enctype)
955
-
1102
+ def update_rate_limit(response)
1103
+ @rate_limit.limit = @rate_limit.remaining = @rate_limit.reset = nil
956
1104
 
957
- http = Net::HTTP.new(url.hostname, url.port)
958
- http.use_ssl = (url.scheme == 'https')
1105
+ @rate_limit.limit = response['X-RateLimit-Limit'].to_i
1106
+ @rate_limit.remaining = response['X-RateLimit-Remaining'].to_i
959
1107
 
960
- if @debug_http
961
- http.set_debug_output(STDERR)
962
- request['Accept-Encoding'] = 'identity'
963
- end
1108
+ reset = response['X-RateLimit-Reset']
1109
+ @rate_limit.reset = Time.iso8601(reset) if reset
964
1110
 
965
- return http.start {|http|
966
- http.request(request)
967
- }
1111
+ @logger.debug "Rate limit: #{rate_limit_desc}"
968
1112
  end
1113
+
969
1114
  end
1115
+
970
1116
  end
@@ -0,0 +1,4 @@
1
+
2
+ module Shep
3
+ Version = '0.2.0-alpha0'
4
+ end