numerousapp 0.9.2 → 1.0.0

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 (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