numerousapp 0.9.2 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/lib/numerousapp.rb +416 -15
  3. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 26caf302981f15e44e4c555ef9c43562dd7fad63
4
- data.tar.gz: df17bba5974e81bf6410969923cd9a3de9c4e2d9
3
+ metadata.gz: 3bdd91b07bfb89bd80ad1a1ab27e1ca922b50e43
4
+ data.tar.gz: c94f71b9997951d8ad71f906bd1410905ef90f10
5
5
  SHA512:
6
- metadata.gz: 0a2051121d9d80a8e65080e86bff2aac1a59988c5afce9a28fe700dca54802b5401c732325c1770d8bd8cc5ffb4503d2b765ab90c0eb6ccf463d71023b19c311
7
- data.tar.gz: 5cdef175e815cf56137cd8bc506928a6233c1413e67d94e8b77b20d312dd14dd049e7a295a887cbdc322c30e711350398f7bdaedbc18f27d585c9003881a8650
6
+ metadata.gz: 6eef3a14e6f0342a39062943b155e7bca3222747ad4d9f15049f3194ce0c464379da64c81a87b76cdf9196cf77c1282dc337996fa5495e962223bd705503a310
7
+ data.tar.gz: 3222957cbdcea1304bad84c199cf2aa3767066eb4b52f048494bc65207018c78f98a9b92a914ba12aa26bdeb352a88c29c8b97dd94e15c1ece41d1b97050df00
data/lib/numerousapp.rb CHANGED
@@ -88,9 +88,13 @@ end
88
88
  #
89
89
  class NumerousClientInternals
90
90
 
91
+
92
+
91
93
  #
92
94
  # @param apiKey [String] API authentication key
93
95
  # @param server [String] Optional (keyword arg). Server name.
96
+ # @param throttle [Proc] Optional throttle policy
97
+ # @param throttleData [Any] Optional data for throttle
94
98
  #
95
99
  # @!attribute agentString
96
100
  # @return [String] User agent string sent to the server.
@@ -101,8 +105,12 @@ class NumerousClientInternals
101
105
  # @!attribute [r] debugLevel
102
106
  # @return [Fixnum] Current debugging level; use debug() method to change.
103
107
  #
104
- def initialize(apiKey, server:'api.numerousapp.com')
108
+ def initialize(apiKey, server:'api.numerousapp.com',
109
+ throttle:nil, throttleData:nil)
105
110
 
111
+ if not apiKey
112
+ apiKey = Numerous.numerousKey()
113
+ end
106
114
 
107
115
  @serverName = server
108
116
  @auth = { user: apiKey, password: "" }
@@ -113,10 +121,32 @@ class NumerousClientInternals
113
121
  @agentString = "NW-Ruby-NumerousClass/" + VersionString +
114
122
  " (Ruby #{RUBY_VERSION}) NumerousAPI/v2"
115
123
 
124
+ @filterDuplicates = true # see discussion elsewhere
125
+
126
+ # throttling.
127
+ # The arbitraryMaximum is just that: under no circumstances will we retry
128
+ # any particular request more than that. Tough noogies.
129
+ #
130
+ # the throttlePolicy "tuple" is:
131
+ # [ 0 ] - Proc
132
+ # [ 1 ] - specific data for Proc
133
+ # [ 2 ] - "up" tuple for chained policy
134
+ #
135
+ # and the default policy uses the "data" (40) as the voluntary backoff point
136
+ #
137
+ @arbitraryMaximumTries = 10
138
+ @throttlePolicy = [ThrottleDefault, 40, nil]
139
+ if throttle
140
+ @throttlePolicy = [throttle, throttleData, @throttlePolicy]
141
+ end
142
+
143
+ @statistics = Hash.new { |h, k| h[k] = 0 } # just useful debug/testing info
116
144
  @debugLevel = 0
145
+
117
146
  end
118
147
  attr_accessor :agentString
119
148
  attr_reader :serverName, :debugLevel
149
+ attr_reader :statistics
120
150
 
121
151
  # Set the debug level
122
152
  #
@@ -134,9 +164,17 @@ class NumerousClientInternals
134
164
  return prev
135
165
  end
136
166
 
167
+ # XXX This is primarily for testing; control filtering of bogus duplicates
168
+ # If you are calling this you are probably doing something wrong.
169
+ def setBogusDupFilter(f)
170
+ prev = @filterDuplicates
171
+ @filterDuplicates = f
172
+ return prev
173
+ end
174
+
137
175
  protected
138
176
 
139
- VersionString = '20141224.1'
177
+ VersionString = '20150123-1.0.0'
140
178
 
141
179
  MethMap = {
142
180
  GET: Net::HTTP::Get,
@@ -228,6 +266,8 @@ class NumerousClientInternals
228
266
  #
229
267
  def simpleAPI(api, jdict:nil, multipart:nil, url:nil)
230
268
 
269
+ @statistics[:simpleAPI] += 1
270
+
231
271
  # take the base url if you didn't give us an override
232
272
  url ||= api[:basePath]
233
273
 
@@ -279,14 +319,40 @@ class NumerousClientInternals
279
319
  end
280
320
  end
281
321
 
282
- resp = @http.request(rq)
283
-
284
- if @debugLevel > 0
285
- puts "Response headers:\n"
286
- resp.each do | k, v |
287
- puts "k: " + k + " :: " + v + "\n"
322
+ resp = nil # ick, is there a better way to get this out of the block?
323
+ @arbitraryMaximumTries.times do |attempt|
324
+
325
+ @statistics[:serverRequests] += 1
326
+ resp = @http.request(rq)
327
+
328
+ if @debugLevel > 0
329
+ puts "Response headers:\n"
330
+ resp.each do | k, v |
331
+ puts "k: " + k + " :: " + v + "\n"
332
+ end
333
+ puts "Code: " + resp.code + "/" + resp.code.class.to_s + "/\n"
334
+ end
335
+
336
+ # invoke the rate-limiting policy
337
+ rateRemain = resp['x-rate-limit-remaining'].to_i
338
+ rateReset = resp['x-rate-limit-reset'].to_i
339
+ @statistics[:rateRemaining] = rateRemain
340
+ @statistics[:rateReset] = rateReset
341
+
342
+ tp = { :debug=> @debug,
343
+ :attempt=> attempt,
344
+ :rateRemaining=> rateRemain,
345
+ :rateReset=> rateReset,
346
+ :resultCode=> resp.code.to_i,
347
+ :resp=> resp,
348
+ :statistics=> @statistics,
349
+ :request=> { :httpMethod => api[:httpMethod], :url => path } }
350
+
351
+ td = @throttlePolicy[1]
352
+ up = @throttlePolicy[2]
353
+ if not @throttlePolicy[0].call(self, tp, td, up)
354
+ break
288
355
  end
289
- puts "Code: " + resp.code + "/" + resp.code.class.to_s + "/\n"
290
356
  end
291
357
 
292
358
  goodCodes = api[:successCodes] || [200]
@@ -352,6 +418,14 @@ class NumerousClientInternals
352
418
  api = makeAPIcontext(info, :GET, subs)
353
419
  list = []
354
420
  nextURL = api[:basePath]
421
+ firstTime = true
422
+
423
+ # see discussion about duplicate filtering below
424
+ if @filterDuplicates and api[:dupFilter]
425
+ filterInfo = { prev: {}, current: {} }
426
+ else
427
+ filterInfo = nil
428
+ end
355
429
 
356
430
  while nextURL
357
431
  # get a chunk from the server
@@ -362,16 +436,167 @@ class NumerousClientInternals
362
436
  # But here we're just letting the whatever-exceptions filter up
363
437
  v = simpleAPI(api, url:nextURL)
364
438
 
439
+ # statistics, helpful for testing/debugging. Algorithmically
440
+ # we don't really care about first time or not, just for the stats
441
+ if firstTime
442
+ @statistics[:firstChunks] += 1
443
+ firstTime = false
444
+ else
445
+ @statistics[:additionalChunks] += 1
446
+ end
447
+
448
+ if filterInfo
449
+ filterInfo[:prev] = filterInfo[:current]
450
+ filterInfo[:current] = {}
451
+ end
452
+
365
453
  list = v[api[:list]]
366
454
  nextURL = v[api[:next]]
367
455
 
368
456
  # hand them out
369
457
  if list # can be nil for a variety of reasons
370
- list.each { |i| block.call i }
458
+ list.each do |i|
459
+
460
+ # A note about duplicate filtering
461
+ #
462
+ # There is a bug in the NumerousApp server which can
463
+ # cause collections to show duplicates of certain events
464
+ # (or interactions/stream items). Explaining the bug in great
465
+ # detail is beyond the scope here; suffice to say it only
466
+ # happens for events that were recorded nearly-simultaneously
467
+ # and happen to be getting reported right at a chunking boundary.
468
+ #
469
+ # So we are filtering them out here. For a more involved
470
+ # discussion of this, see the python implementation. This
471
+ # filtering "works" because it knows pragmatically how/where
472
+ # the bug can show up
473
+ #
474
+ # Turning off duplicate filtering is really meant only for testing.
475
+ #
476
+ # Not all API's require dupfiltering, hence the APIInfo test
477
+ #
478
+ if (not filterInfo) # the easy case, not filtering
479
+ block.call i
480
+ else
481
+ thisId = i[api[:dupFilter]]
482
+ if filterInfo[:prev].include? thisId
483
+ @statistics[:duplicatesFiltered] += 1
484
+ else
485
+ filterInfo[:current][thisId] = 1
486
+ block.call i
487
+ end
488
+ end
489
+ end
371
490
  end
372
491
  end
373
492
  return nil # the subclasses return (should return) their own self
374
493
  end
494
+
495
+ #
496
+ # The default throttle policy.
497
+ # Invoked after the response has been received and we are supposed to
498
+ # return true to force a retry or false to accept this response as-is.
499
+ #
500
+ # The policy this implements:
501
+ # if the server failed with too busy, do backoff based on attempt number
502
+ #
503
+ # if we are "getting close" to our limit, arbitrarily delay ourselves
504
+ #
505
+ # if we truly got spanked with "Too Many Requests"
506
+ # then delay the amount of time the server told us to delay.
507
+ #
508
+ # The arguments supplied to us are:
509
+ # nr is the Numerous (handled explicitly so you can write external funcs too)
510
+ # tparams is a Hash containing:
511
+ # :attempt : the attempt number. Zero on the very first try
512
+ # :rateRemaining : X-Rate-Limit-Remaining reported by the server
513
+ # :rateReset : time (in seconds) until fresh rate granted
514
+ # :resultCode : HTTP code from the server (e.g., 409, 200, etc)
515
+ # :resp : the full-on response object if you must have it
516
+ # :request : information about the original request
517
+ # :statistics : place to record stats (purely informational stats)
518
+ # :debug : current debug level
519
+ #
520
+ # td is the data you supplied as "throttleData" to the Numerous() constructor
521
+ # up is a tuple useful for calling the original system throttle policy:
522
+ # up[0] is the function pointer
523
+ # up[1] is the td for *that* function
524
+ # up[2] is the "up" for calling *that* function
525
+ # ... so after you do your own thing if you then want to defer to the
526
+ # built-in throttle policy you can
527
+ # return send(up[0], nr, tparams, up[1], up[2])
528
+ #
529
+ # It's really (really really) important to understand the return value and
530
+ # the fact that we are invoked AFTER each request:
531
+ # false : simply means "don't do more retries". It does not imply anything
532
+ # about the success or failure of the request; it simply means that
533
+ # this most recent request (response) is the one to "take" as
534
+ # the final answer
535
+ #
536
+ # true : means that the response is, indeed, to be interpreted as some
537
+ # sort of rate-limit failure and should be discarded. The original
538
+ # request will be sent again. Obviously it's a very bad idea to
539
+ # return true in cases where the server might have done some
540
+ # anything non-idempotent. We assume that a 429 ("Too Many") or
541
+ # a 500 ("Too Busy") response from the server means the server didn't
542
+ # actually do anything (so a retry, timed appropriately, is ok)
543
+ #
544
+ # All of this seems overly general for what basically amounts to "sleep sometimes"
545
+ #
546
+
547
+ ThrottleDefault = Proc.new do |nr, tparams, td, up|
548
+ rateleft = tparams[:rateRemaining]
549
+ attempt = tparams[:attempt] # note: is zero on very first try
550
+ stats = tparams[:statistics]
551
+
552
+ if attempt > 0
553
+ stats[:throttleMultipleAttempts] += 1
554
+ end
555
+
556
+ backarray = [ 2, 5, 15, 30, 60 ]
557
+ if attempt < backarray.length
558
+ backoff = backarray[attempt]
559
+ else
560
+ stats[:throttleMaxed] += 1
561
+ next false # too many tries
562
+ end
563
+
564
+ # if the server actually has failed with too busy, sleep and try again
565
+ if tparams[:resultCode] == 500
566
+ stats[:throttle500] += 1
567
+ sleep(backoff)
568
+ next true
569
+ end
570
+
571
+ # if we weren't told to back off, no need to retry
572
+ if tparams[:resultCode] != 429
573
+ # but if we are closing in on the limit then slow ourselves down
574
+ # note that some errors don't communicate rateleft so we have to
575
+ # check for that as well (will be -1 here if wasn't sent to us)
576
+ #
577
+ # at constructor time our "throttle data" (td) was set up as the
578
+ # voluntary arbitrary limit
579
+ if rateleft >= 0 and rateleft < td
580
+ stats[:throttleVoluntaryBackoff] += 1
581
+ # arbitrary .. 1 second if more than half left, 3 seconds if less
582
+ if (rateleft*2) > td
583
+ sleep(1)
584
+ else
585
+ sleep(3)
586
+ end
587
+ end
588
+ next false # no retry
589
+ end
590
+
591
+ # decide how long to delay ... we just wait for as long as the
592
+ # server told us to (plus "backoff" seconds slop to really be sure we
593
+ # aren't back too soon)
594
+ stats[:throttle429] += 1
595
+ sleep(tparams[:rateReset] + backoff)
596
+ next true
597
+ end
598
+ private_constant :ThrottleDefault
599
+
375
600
  end
376
601
 
377
602
  #
@@ -584,6 +809,145 @@ class Numerous < NumerousClientInternals
584
809
  return NumerousMetric.new(id, self)
585
810
  end
586
811
 
812
+
813
+ #
814
+ # Version of metric() that accepts a name (label)
815
+ # instead of an ID, and can even process it as a regexp.
816
+ #
817
+ # @param [String] labelspec The name (label) or regexp
818
+ # @param [String] matchType 'FIRST' or 'BEST' or 'ONE' or 'STRING' (not regexp)
819
+ # @param [Numerous] nr
820
+ # The {Numerous} object that will be used to access this metric.
821
+ def metricByLabel(labelspec, matchType:'FIRST')
822
+ def raiseConflict(s1,s2)
823
+ raise NumerousMetricConflictError.new("Multiple matches", 409, [s1, s2])
824
+ end
825
+
826
+ bestMatch = [ nil, 0 ]
827
+
828
+ if not matchType
829
+ matchType = 'FIRST'
830
+ end
831
+ if not ['FIRST', 'BEST', 'ONE', 'STRING'].include?(matchType)
832
+ raise ArgumentError
833
+ end
834
+
835
+ self.metrics do |m|
836
+ if matchType == 'STRING' # exact full match required
837
+ if m['label'] == labelspec
838
+ if bestMatch[0]
839
+ raiseConflict(bestMatch[0]['label'], m['label'])
840
+ end
841
+ bestMatch = [ m, 1 ] # the length is irrelevant with STRING
842
+ end
843
+ else
844
+ mm = labelspec.match(m['label'])
845
+ if mm
846
+ if matchType == 'FIRST'
847
+ return self.metric(m['id'])
848
+ elsif (matchType == 'ONE') and (bestMatch[1] > 0)
849
+ raiseConflict(bestMatch[0]['label'], m['label'])
850
+ end
851
+ if mm[0].length > bestMatch[1]
852
+ bestMatch = [ m, mm[0].length ]
853
+ end
854
+ end
855
+ end
856
+ end
857
+ rv = nil
858
+ if bestMatch[0]
859
+ rv = self.metric(bestMatch[0]['id'])
860
+ end
861
+ end
862
+
863
+
864
+ #
865
+ # I found this a good way to handle supplying the API key so it's here
866
+ # as a class method you may find useful. What this function does is
867
+ # return you an API Key from a supplied string or "readable" object:
868
+ #
869
+ # a "naked" API key (in which case this function is a no-op)
870
+ # @- :: meaning "get it from stdin"
871
+ # @blah :: meaning "get it from the file "blah"
872
+ # /blah :: get it from file /blah
873
+ # .blah :: get it from file .blah (could be ../ etc)
874
+ # /readable/ :: if it has a .read method, get it that way
875
+ # None :: get it from environment variable NUMEROUSAPIKEY
876
+ #
877
+ # Where the "it" that is being gotten from any of those sources can be:
878
+ # a "naked" API key
879
+ # a JSON object, from which the credsAPIKey will be used to get the key
880
+ #
881
+ # Arguably this doesn't belong here, but it's helpful. Purists are free to
882
+ # ignore it or delete from their own tree :)
883
+ #
884
+
885
+ def self.numerousKey(s:nil, credsAPIKey:'NumerousAPIKey')
886
+
887
+ if not s
888
+ # try to get from environment
889
+ s = ENV['NUMEROUSAPIKEY']
890
+ if not s
891
+ return nil
892
+ end
893
+ end
894
+
895
+ closeThis = nil
896
+
897
+ if s == "@-" # creds coming from stdin
898
+ s = STDIN
899
+
900
+ # see if they are in a file
901
+ else
902
+ begin
903
+ if s.length() > 0 # is it a string or a file object?
904
+ # it's stringy - if it looks like a file open it or fail
905
+ begin
906
+ if s.length() > 1 and s[0] == '@'
907
+ s = open(s[1..-1])
908
+ closeThis = s
909
+ elsif s[0] == '/' or s[0] == '.'
910
+ s = open(s)
911
+ closeThis = s
912
+ end
913
+ rescue
914
+ return nil
915
+ end
916
+ end
917
+ rescue NoMethodError # it wasn't stringy, presumably it's a "readable"
918
+ end
919
+ end
920
+
921
+ # well, see if whatever it is, is readable, and go with that if it is
922
+ begin
923
+ v = s.read()
924
+ if closeThis
925
+ closeThis.close()
926
+ end
927
+ s = v
928
+ rescue NoMethodError
929
+ end
930
+
931
+ # at this point s is either a JSON or a naked cred (or bogus)
932
+ begin
933
+ j = JSON.parse(s)
934
+ rescue TypeError, JSON::ParserError
935
+ j = {}
936
+ end
937
+
938
+
939
+ #
940
+ # This is kind of a hack and might hide some errors on your part
941
+ #
942
+ if not j.include? credsAPIKey # this is how the naked case happens
943
+ # replace() bcs there might be a trailing newline on naked creds
944
+ # (usually happens with a file or stdin)
945
+ j[credsAPIKey] = s.sub("\n",'')
946
+ end
947
+
948
+ return j[credsAPIKey]
949
+ end
950
+
587
951
  end
588
952
 
589
953
  #
@@ -623,12 +987,48 @@ class NumerousMetric < NumerousClientInternals
623
987
  # @param [String] id The metric ID string.
624
988
  # @param [Numerous] nr
625
989
  # The {Numerous} object that will be used to access this metric.
990
+ #
991
+ # "id" should normally be the naked metric id (as a string).
992
+ #
993
+ # It can also be a nmrs: URL, e.g.:
994
+ # nmrs://metric/2733614827342384
995
+ #
996
+ # Or a 'self' link from the API:
997
+ # https://api.numerousapp.com/metrics/2733614827342384
998
+ #
999
+ # in either case we get the ID in the obvious syntactic way.
1000
+ #
1001
+ # It can also be a metric's web link, e.g.:
1002
+ # http://n.numerousapp.com/m/1x8ba7fjg72d
1003
+ #
1004
+ # in which case we "just know" that the tail is a base36
1005
+ # encoding of the ID.
1006
+ #
1007
+ # The decoding logic here makes the specific assumption that
1008
+ # the presence of a '/' indicates a non-naked metric ID. This
1009
+ # seems a reasonable assumption given that IDs have to go into URLs
1010
+ #
1011
+
626
1012
  def initialize(id, nr)
1013
+ begin
1014
+ if id.include? '/'
1015
+ fields = id.split('/')
1016
+ if fields[-2] == "m"
1017
+ id = fields[-1].to_i(36)
1018
+ else
1019
+ id = fields[-1]
1020
+ end
1021
+ end
1022
+ rescue NoMethodError
1023
+ id = id.to_s
1024
+ end
1025
+
627
1026
  @id = id
628
1027
  @nr = nr
629
1028
  end
630
1029
  attr_reader :id
631
1030
 
1031
+
632
1032
  #
633
1033
  # Obtain the {Numerous} server object associated with a metric.
634
1034
  # @return [Numerous]
@@ -652,7 +1052,8 @@ class NumerousMetric < NumerousClientInternals
652
1052
  path: '/v1/metrics/%{metricId}/events',
653
1053
  GET: {
654
1054
  next: 'nextURL',
655
- list: 'events'
1055
+ list: 'events',
1056
+ dupFilter: 'id'
656
1057
  },
657
1058
  POST: {
658
1059
  successCodes: [ 201 ]
@@ -672,7 +1073,8 @@ class NumerousMetric < NumerousClientInternals
672
1073
  path: '/v2/metrics/%{metricId}/stream',
673
1074
  GET: {
674
1075
  next: 'next',
675
- list: 'items'
1076
+ list: 'items',
1077
+ dupFilter: 'id'
676
1078
  }
677
1079
  },
678
1080
 
@@ -681,7 +1083,8 @@ class NumerousMetric < NumerousClientInternals
681
1083
  path: '/v2/metrics/%{metricId}/interactions',
682
1084
  GET: {
683
1085
  next: 'nextURL',
684
- list: 'interactions'
1086
+ list: 'interactions',
1087
+ dupFilter: 'id'
685
1088
  },
686
1089
  POST: {
687
1090
  successCodes: [ 201 ]
@@ -1117,5 +1520,3 @@ class NumerousMetric < NumerousClientInternals
1117
1520
 
1118
1521
  end
1119
1522
 
1120
-
1121
-
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: numerousapp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.2
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Neil Webber
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-12-24 00:00:00.000000000 Z
11
+ date: 2015-01-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Classes implementing the NumerousApp REST APIs for metrics. Requires
14
14
  Ruby 2.x