Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Building a Resilient API with open source

Building a Resilient API with open source

Tools and techniques GitHub employs to handle API changes.

7e19cd5486b5d6dc1ef90e671ba52ae0?s=128

Wynn Netherland

July 23, 2014
Tweet

Transcript

  1. ! Building a Resilient API with open source Wynn Netherland

    GitHub
  2. ! Hello, my name is Wynn.

  3. None
  4.  Folks call me @pengwynn.

  5. ! I work @ GitHub.

  6. ! GitHub makes software for people who make software.

  7. ! I work mostly on the GitHub API.

  8. ! I make software for computers who work for people

    who make software and other stuff. — me, at Thanksgiving ” ”
  9. ! Building a Resilient API with open source I want

    to talk about
  10. ! re·sil·ient /ri'zilyənt/ able to recoil or spring back into

    shape after stretching, bending, or compressing "
  11. ! Stretching

  12. ! # LOAD #

  13. ! Bending

  14. ! Feature changes

  15. ! % Bug fixes

  16. ! Compressing

  17. ! Feature removal

  18. ! Internal refactoring

  19. ! Infrastructure changes

  20. ! Change happens.

  21. ! Who 301'd my cheese?

  22. ! measure change & minimize change ' handle change (

    At GitHub, we so we can in order to
  23. ! Measuring change &

  24. ! Use data to inform decisions.

  25. ! How often is this feature used?

  26. ! Who is using this feature?

  27. ! ”If an API method is removed in the woods,

    and nobody is there to call it, does it still respond with 410?” ! — unknown
  28. ! /graph me -1hour @api.status.codes !

  29. ! ) Chatops

  30. ! Hubot *

  31. ! hubot.github.com

  32. ! /graph me -1hr @api.queries.serialized !

  33. ! /graph me -3hr @api.events.delivered !

  34. ! The Graph Store +

  35. ! The Graph Store + There's a graph for that™

  36. ! The Graph Store + There's a graph for that™

    …if not, make one.
  37. ! Built on Graphite

  38. !

  39. !

  40. ! , Instrumentation

  41. ! # counter GitHub.stats.increment("the-thing.the-action") ! # gauge GitHub.stats.gauge("the-thing.the-gauge", thing.current_reading) !

    # timing GitHub.stats.time("the-thing.the-operation") { the_operation } -
  42. ! # counter GitHub.stats.increment("the-thing.the-action") ! # gauge GitHub.stats.gauge("the-thing.the-gauge", thing.current_reading) !

    # timing GitHub.stats.time("the-thing.the-operation") { the_operation } -
  43. ! # counter GitHub.stats.increment("the-thing.the-action") ! # gauge GitHub.stats.gauge("the-thing.the-gauge", thing.current_reading) !

    # timing GitHub.stats.time("the-thing.the-operation") { the_operation } -
  44. ! github.com/graphite-project

  45. ! . Logging

  46. ! /splunk -3m @production "timeline.json" ! pengwynn: ! 2014-07-10T10:12:19-07:00 github-fe126-cp1-prd

    local7:info unicorn_rails[11990]: app=github env=production enterprise=false now="2014-07-10T10:12:19-07:00" request_id="AD003EA0:5BD8:4B8AE05:53BEC973" remote_address=192.168.1.1 request_method=get path_info="/timeline.json" content_length=375 user_agent="@gitlost account on twitter, Python urllib2" accept=nil language=nil status=410 at=finish elapsed=0.005 2014-07-10T10:12:19-07:00 github-fe126-cp1-prd local7:info unicorn_rails[11990]: app=github env=production enterprise=false now="2014-07-10T10:12:19-07:00" user=nil repo=nil route=nil zone=UTC referrer=nil requested_at="2014-07-10 17:12:19 UTC" url="https://github.com/timeline.json" controller=EventsController action=index request_id="AD003EA0:5BD8:4B8AE05:53BEC973" dbconn=write serializer=json at=start 2014-07-10T10:12:19-07:00 github-fe117-cp1-prd local7:info unicorn_rails[12813]: app=github env=production enterprise=false now="2014-07-10T10:12:19-07:00"
  47. ! /splunk -3m @production "timeline.json" ! pengwynn: ! 2014-07-10T10:12:19-07:00 github-fe126-cp1-prd

    local7:info unicorn_rails[11990]: app=github env=production enterprise=false now="2014-07-10T10:12:19-07:00" request_id="AD003EA0:5BD8:4B8AE05:53BEC973" remote_address=192.168.1.1 request_method=get path_info="/timeline.json" content_length=375 user_agent="@gitlost account on twitter, Python urllib2" accept=nil language=nil status=410 at=finish elapsed=0.005 2014-07-10T10:12:19-07:00 github-fe126-cp1-prd local7:info unicorn_rails[11990]: app=github env=production enterprise=false now="2014-07-10T10:12:19-07:00" user=nil repo=nil route=nil zone=UTC referrer=nil requested_at="2014-07-10 17:12:19 UTC" url="https://github.com/timeline.json" controller=EventsController action=index request_id="AD003EA0:5BD8:4B8AE05:53BEC973" dbconn=write serializer=json at=start 2014-07-10T10:12:19-07:00 github-fe117-cp1-prd local7:info unicorn_rails[12813]: app=github env=production enterprise=false now="2014-07-10T10:12:19-07:00"
  48. ! /splunk -3m @production "timeline.json" | stats count by status,

    avg(elapsed) ! pengwynn: ┌──────┬────────────┐ │status│avg(elapsed)│ ├──────┼────────────┤ │200 │0.067745 │ ├──────┼────────────┤ │410 │0.005597 │ └──────┴────────────┘
  49. ! key=value logging

  50. ! / app=github env=development enterprise=false now="2014-07-21T11:49:45-07:00" request_id=f80765a8-8aa9-4e92-b32b-01300193631a remote_address=127.0.0.1 request_method=get path_info="/"

    content_length=1866 user_agent=curl/7.30.0 accept=*/* language=nil status=200 at=finish elapsed=0.110
  51. ! / app=github env=development enterprise=false now="2014-07-21T11:49:45-07:00" request_id=f80765a8-8aa9-4e92-b32b-01300193631a remote_address=127.0.0.1 request_method=get path_info="/"

    content_length=1866 user_agent=curl/7.30.0 accept=*/* language=nil status=200 at=finish elapsed=0.110
  52. ! curl https://api.github.com/ -I | grep Request-Id ❯ / !

    X-GitHub-Request-Id: 4317CCE2:6434:21DCB73:53CD6239
  53. ! curl https://api.github.com/ -I | grep Request-Id ❯ / !

    X-GitHub-Request-Id: 4317CCE2:6434:21DCB73:53CD6239 A single identifier to trace requests through logging.
  54. ! /splunk -3m @production request_id=f80765a8-8aa9-4e92- b32b-01300193631a !

  55. ! github.com/asenchi/scrolls

  56. ! github.com/technoweenie/grohl If Golang is your thang.

  57. ! Logstash, Splunk*, Loggly* *SaaS solutions, not open source

  58. ! Minimizing change '

  59. ! Isolation

  60. ! tree -d -L 2 github ❯ / github ├──

    app │ ├── api │ ├── assets │ ├── controllers │ ├── helpers │ ├── mailers │ ├── models │ ├── view_models │ └── views ├── bin ├── config │ ├── inititalizers ...
  61. ! tree -d -L 2 github ❯ / github ├──

    app │ ├── api │ ├── assets │ ├── controllers │ ├── helpers │ ├── mailers │ ├── models │ ├── view_models │ └── views ├── bin ├── config │ ├── inititalizers ... Sinatra app inside a Rails app
  62. ! tree -d -L 2 github ❯ / github ├──

    app │ ├── api │ ├── assets │ ├── controllers │ ├── helpers │ ├── mailers │ ├── models │ ├── view_models │ └── views ├── bin ├── config │ ├── inititalizers ... Sinatra app inside a Rails app
  63. ! Shared business logic data access logging instrumentation plumbing Independent

    workflows output
  64. ! Testing ❯ testrb test/integration/api/repos_test.rb Loading suite in dotcom mode

    ... Run options: # Running tests: Finished tests in 30.638101s, 4.7979 tests/s, 64.7886 assertions/s. 147 tests, 1985 assertions, 0 failures, 0 errors, 0 skips ruby -v: ruby 2.1.0p0-github (development) [x86_64-darwin13.0]
  65. ! Rack::Test

  66. ! brynary/rack-test github.com/

  67. ! # test/integration/api/root_test.rb ! test "provides hypermedia URLs to top-level

    resources" do get "/" data = JSON.parse(last_response.body) assert_equal 200, last_response.status assert_equal "https://#{GitHub.api_host_name}/user", data["current_user_url"] end -
  68. ! # test/integration/api/root_test.rb ! test "provides hypermedia URLs to top-level

    resources" do get "/" data = JSON.parse(last_response.body) assert_equal 200, last_response.status assert_equal "https://#{GitHub.api_host_name}/user", data["current_user_url"] end -
  69. ! # test/integration/api/root_test.rb ! test "provides hypermedia URLs to top-level

    resources" do data = api :get, "/" ! assert_equal 200, last_response.status assert_equal "https://#{GitHub.api_host_name}/user", data["current_user_url"] end -
  70. ! JSON Schema

  71. ! json-schema.org

  72. ! Validating output.

  73. ! / # Running tests: ! [1/5] Api::RateLimitTest#test_OAuth_app_with_custom_rate_limit_L63 = 2.06

    s 1) Failure: Api::RateLimitTest#test_OAuth_app_with_custom_rate_limit_L63 [/Users/ wynn/github/github/test/integration/api/rate_limit_test.rb:67]: {"resources"=>{"core"=>{}, "search"=>{}}, "rate"=>{}} The property '#/rate' did not contain a required property of 'limit' in schema c1ac8566-70fa-5c34-b050-0cfa6801c1c1# The property '#/rate' did not contain a required property of 'remaining' in schema c1ac8566-70fa-5c34-b050-0cfa6801c1c1# The property '#/rate' did not contain a required property of 'reset' in schema c1ac8566-70fa-5c34-b050-0cfa6801c1c1#
  74. ! / # Running tests: ! [1/5] Api::RateLimitTest#test_OAuth_app_with_custom_rate_limit_L63 = 2.06

    s 1) Failure: Api::RateLimitTest#test_OAuth_app_with_custom_rate_limit_L63 [/Users/ wynn/github/github/test/integration/api/rate_limit_test.rb:67]: {"resources"=>{"core"=>{}, "search"=>{}}, "rate"=>{}} The property '#/rate' did not contain a required property of 'limit' in schema c1ac8566-70fa-5c34-b050-0cfa6801c1c1# The property '#/rate' did not contain a required property of 'remaining' in schema c1ac8566-70fa-5c34-b050-0cfa6801c1c1# The property '#/rate' did not contain a required property of 'reset' in schema c1ac8566-70fa-5c34-b050-0cfa6801c1c1#
  75. ! / # Running tests: ! [1/5] Api::RateLimitTest#test_OAuth_app_with_custom_rate_limit_L63 = 2.06

    s 1) Failure: Api::RateLimitTest#test_OAuth_app_with_custom_rate_limit_L63 [/Users/ wynn/github/github/test/integration/api/rate_limit_test.rb:67]: {"resources"=>{"core"=>{}, "search"=>{}}, "rate"=>{}} The property '#/rate' did not contain a required property of 'limit' in schema c1ac8566-70fa-5c34-b050-0cfa6801c1c1# The property '#/rate' did not contain a required property of 'remaining' in schema c1ac8566-70fa-5c34-b050-0cfa6801c1c1# The property '#/rate' did not contain a required property of 'reset' in schema c1ac8566-70fa-5c34-b050-0cfa6801c1c1#
  76. ! Validating input.

  77. ! / # update a label $ curl -ni http://api.github.dev/repos/defunkt/dotjs/labels/bug

    \ -X PATCH -H "Content-Type: application/json" \ -d '{ "name":[], "color":42 } ! HTTP/1.1 422 Unprocessable Entity ! { "message": "Invalid request.\n\nExpected data to be of type \"string \"; value was: [].\nExpected data to be of type \"string\"; value was: 42.", "documentation_url": "https://developer.github.com/v3/issues/labels/ #update-a-label" }
  78. ! / # update a label $ curl -ni http://api.github.dev/repos/defunkt/dotjs/labels/bug

    \ -X PATCH -H "Content-Type: application/json" \ -d '{ "name":[], "color":42 } ! HTTP/1.1 422 Unprocessable Entity ! { "message": "Invalid request.\n\nExpected data to be of type \"string \"; value was: [].\nExpected data to be of type \"string\"; value was: 42.", "documentation_url": "https://developer.github.com/v3/issues/labels/ #update-a-label" }
  79. ! MiniTest

  80. ! Custom assertions

  81. ! test "can create a pull request" do auth_as @forker

    ! data = api :post, @pub_pulls_url, {}, :input => { :title => "new pr", :body => "this is an amazing change", :base => "master", :head => "#{@forker}:ahead" }.to_json assert_equal 201, last_response.status assert_schema :v3, :pull_request, data end -
  82. ! test "can create a pull request" do auth_as @forker

    ! data = api :post, @pub_pulls_url, {}, :input => { :title => "new pr", :body => "this is an amazing change", :base => "master", :head => "#{@forker}:ahead" }.to_json assert_equal 201, last_response.status assert_schema :v3, :pull_request, data end -
  83. ! test "can retrieve pull requests" do auth_as @owner !

    data = api :get, @pub_pulls_url assert_equal 200, last_response.status ! assert_schema_for_collection \ :v3, :pull_request_simple, data end -
  84. ! test "can retrieve pull requests" do auth_as @owner !

    data = api :get, @pub_pulls_url assert_equal 200, last_response.status ! assert_schema_for_collection \ :v3, :pull_request_simple, data end -
  85. ! Even more assertions

  86. ! test "authz list commits for pull request" do !

    assert_authz :get, @priv_commits_url do |check| check.disallows_with :not_found check.authorized_users @owner, @collaborator check.unauthorized_users :anon, @rando check.minimum_scopes :repo end end -
  87. ! test "authz list commits for pull request" do !

    assert_authz :get, @priv_commits_url do |check| check.disallows_with :not_found check.authorized_users @owner, @collaborator check.unauthorized_users :anon, @rando check.minimum_scopes :repo end end -
  88. ! test "authz list commits for pull request" do !

    assert_authz :get, @priv_commits_url do |check| check.disallows_with :not_found check.authorized_users @owner, @collaborator check.unauthorized_users :anon, @rando check.minimum_scopes :repo end end -
  89. ! test "authz list commits for pull request" do !

    assert_authz :get, @priv_commits_url do |check| check.disallows_with :not_found check.authorized_users @owner, @collaborator check.unauthorized_users :anon, @rando check.minimum_scopes :repo end end -
  90. ! test "authz list commits for pull request" do !

    assert_authz :get, @priv_commits_url do |check| check.disallows_with :not_found check.authorized_users @owner, @collaborator check.unauthorized_users :anon, @rando check.minimum_scopes :repo end end -
  91. ! test "authz list commits for pull request" do !

    assert_authz :get, @priv_commits_url do |check| check.disallows_with :not_found check.authorized_users @owner, @collaborator check.unauthorized_users :anon, @rando check.minimum_scopes :repo end end -
  92. ! 5 lines = dozens of AuthZ assertions.

  93. ! But what about those critical code paths?

  94. ! What if you don’t trust your tests?

  95. ! Science 0

  96. ! github/dat-science github.com/

  97. ! @jbarnette

  98. ! @rick

  99. !

  100. !

  101. ! Safely observe alternate code paths for non-mutating methods that

    return a value.
  102. ! require "dat/science" ! class MyApp::Widget def allows?(user) experiment =

    Dat::Science::Experiment.new \ "widget-permissions" do |e| ! e.control { old_method } # old way e.candidate { new_method } # new way end ! experiment.run end end -
  103. ! require "dat/science" ! class MyApp::Widget def allows?(user) experiment =

    Dat::Science::Experiment.new \ "widget-permissions" do |e| ! e.control { old_method } # old way e.candidate { new_method } # new way end ! experiment.run end end -
  104. ! require "dat/science" ! class MyApp::Widget def allows?(user) experiment =

    Dat::Science::Experiment.new \ "widget-permissions" do |e| ! e.control { old_method } # old way e.candidate { new_method } # new way end ! experiment.run end end -
  105. ! require "dat/science" ! class MyApp::Widget def allows?(user) experiment =

    Dat::Science::Experiment.new \ "widget-permissions" do |e| ! e.control { old_method } # old way e.candidate { new_method } # new way end ! experiment.run end end -
  106. ! require "dat/science" ! class MyApp::Widget def allows?(user) experiment =

    Dat::Science::Experiment.new \ "widget-permissions" do |e| ! e.control { old_method } # old way e.candidate { new_method } # new way end ! experiment.run end end -
  107. ! require "dat/science" ! class MyApp::Widget def allows?(user) experiment =

    Dat::Science::Experiment.new \ "widget-permissions" do |e| ! e.control { old_method } # old way e.candidate { new_method } # new way end ! experiment.run end end -
  108. Runs candidate before control 50% of the time Measures the

    duration of both behaviors Compares the results of both behaviors Swallows any exceptions raised by the candidate behavior Publishes findings for tracking and reporting
  109. ! Fancy a DSL instead?

  110. ! require "dat/science" ! class MyApp::Widget def allows?(user) science "widget-permissions"

    do |e| e.control { model.check_user(user).valid? } e.candidate { user.can? :read, model } end end end -
  111. ! require "dat/science" ! class MyApp::Widget def allows?(user) science "widget-permissions"

    do |e| e.control { model.check_user(user).valid? } e.candidate { user.can? :read, model } end end end -
  112. ! Create and run.

  113. ! require "dat/science" ! class Blog::Post def permalink(user) science "permalink"

    do |e| e.control { post[:permalink] } e.candidate { post.slug } e.comparator {|a, b| a.downcase == b.downcase } end end end -
  114. ! require "dat/science" ! class Blog::Post def permalink(user) science "permalink"

    do |e| e.control { post[:permalink] } e.candidate { post.slug } e.comparator {|a, b| a.downcase == b.downcase } end end end -
  115. ! Ramping up experiments.

  116. ! require "dat/science" ! module Myapp class Experiment < Dat::Science::Experiment

    def enabled? rand(100) < 10 # Run 10% of the time end end end -
  117. ! github.com/jnunemaker/flipper!

  118. ! require "dat/science" ! module Myapp class Experiment < Dat::Science::Experiment

    def enabled? MyApp.flipper[name].enabled? end end end -
  119. ! Publishing results.

  120. ! require "dat/science" ! module Myapp class Experiment < Dat::Science::Experiment

    def publish MyApp.instrument "science.#{event}", payload end end end -
  121. ! require "dat/science" ! module Myapp class Experiment < Dat::Science::Experiment

    def publish MyApp.instrument "science.#{event}", payload end end end -
  122. None
  123. None
  124. None
  125. ! Adding custom context.

  126. ! require "dat/science" ! class Blog::Post def permalink(user) science "permalink"

    do |e| e.context :post => post e.control { post[:permalink] } e.candidate { post.slug } e.comparator {|a, b| a.downcase == b.downcase } end end end -
  127. ! require "dat/science" ! class Blog::Post def permalink(user) science "permalink"

    do |e| e.context :post => post e.control { post[:permalink] } e.candidate { post.slug } e.comparator {|a, b| a.downcase == b.downcase } end end end -
  128. Mad science.

  129. 1 Don't try this at home

  130. ! module GitHub class Experiment < Dat::Science::Experiment class << self

    # Public: enable mad science mode: returns the candidate values by default # instead of the control. def mad_science=(val) @mad_science = val end ! # Internal: whether or not to always run the candidate instead of the # control. def candidate_instead_of_control? @mad_science end end … end -
  131. ! module GitHub class Experiment < Dat::Science::Experiment class << self

    # Public: enable mad science mode: returns the candidate values by default # instead of the control. def mad_science=(val) @mad_science = val end ! # Internal: whether or not to always run the candidate instead of the # control. def candidate_instead_of_control? @mad_science end end … end -
  132. ! Analyzing results.

  133. ! github.com/github/dat-analysis

  134. ! Build an analyzer to poke on results.

  135. ! require 'dat/analysis' ! module MyApp class Analysis < Dat::Analysis

    def read Redis.rpop "dat-science.#{experiment_name}.results" end ! def count Redis.llen "dat-science.#{experiment_name}.results" end ! def cook(raw_result) return nil unless raw_result JSON.parse(raw_result) end end end -
  136. ! require 'dat/analysis' ! module MyApp class Analysis < Dat::Analysis

    def read Redis.rpop "dat-science.#{experiment_name}.results" end ! def count Redis.llen "dat-science.#{experiment_name}.results" end ! def cook(raw_result) return nil unless raw_result JSON.parse(raw_result) end end end -
  137. ! require 'dat/analysis' ! module MyApp class Analysis < Dat::Analysis

    def read Redis.rpop "dat-science.#{experiment_name}.results" end ! def count Redis.llen "dat-science.#{experiment_name}.results" end ! def cook(raw_result) return nil unless raw_result JSON.parse(raw_result) end end end -
  138. ! require 'dat/analysis' ! module MyApp class Analysis < Dat::Analysis

    def read Redis.rpop "dat-science.#{experiment_name}.results" end ! def count Redis.llen "dat-science.#{experiment_name}.results" end ! def cook(raw_result) return nil unless raw_result JSON.parse(raw_result) end end end -
  139. ! require 'dat/analysis' ! module MyApp class Analysis < Dat::Analysis

    def read Redis.rpop "dat-science.#{experiment_name}.results" end ! def count Redis.llen "dat-science.#{experiment_name}.results" end ! def cook(raw_result) return nil unless raw_result JSON.parse(raw_result) end end end -
  140. ! require 'dat/analysis' ! module MyApp class Analysis < Dat::Analysis

    def read Redis.rpop "dat-science.#{experiment_name}.results" end ! def count Redis.llen "dat-science.#{experiment_name}.results" end ! def cook(raw_result) return nil unless raw_result JSON.parse(raw_result) end end end -
  141. ! / irb> a = MyApp::Analysis.new('widget-permissions') => #<MyApp::Analysis:0x007fae4a0101f8 …> !

    ! !
  142. ! / irb> a = MyApp::Analysis.new('widget-permissions') => #<MyApp::Analysis:0x007fae4a0101f8 …> !

    irb> a.fetch => {"experiment"=>"widget-permissions", "user"=>{ ... } .... } ! !
  143. ! / irb> a = MyApp::Analysis.new('widget-permissions') => #<MyApp::Analysis:0x007fae4a0101f8 …> !

    irb> a.fetch => {"experiment"=>"widget-permissions", "user"=>{ ... } .... } ! irb> a.result => {"experiment"=>"widget-permissions", "user"=>{ ... } .... } !
  144. ! / irb> a.result.control => {"duration"=>12.307, "exception"=>nil, "value"=>false} ! !

    !
  145. ! / irb> a.result.control => {"duration"=>12.307, "exception"=>nil, "value"=>false} ! irb>

    a.result.candidate => {"duration"=>12.366999999999, "exception"=>nil, "value"=>true} !
  146. ! Handling change (

  147. ! New formats

  148. ! Features evolve. URLs don’t have to.

  149. ! / [ "wynn@example.com", "pengwynn@example.com" ] curl -n http://api.github.com/user/emails ❯

  150. ! / [ "wynn@example.com", "pengwynn@example.com" ] curl -n http://api.github.com/user/emails ❯

  151. ! / curl -n http://api.github.com/user/emails \ -H "Accept: application/vnd.github.v3" [

    { "email": "wynn@example.com", "primary": false, "verified": true }, { "email": "pengwynn@example.com", "primary": false, "verified": true } ] ❯
  152. ! / curl -n http://api.github.com/user/emails \ -H "Accept: application/vnd.github.v3" [

    { "email": "wynn@example.com", "primary": false, "verified": true }, { "email": "pengwynn@example.com", "primary": false, "verified": true } ] ❯
  153. ! / curl -n http://api.github.com/user/emails \ -H "Accept: application/vnd.github.v3" [

    { "email": "wynn@example.com", "primary": false, "verified": true }, { "email": "pengwynn@example.com", "primary": false, "verified": true } ] ❯
  154. ! Media types

  155. ! curl https://api.github.com/users/technoweenie -I / HTTP/1.1 200 OK X-GitHub-Media-Type: github.v3

  156. ! curl https://api.github.com/users/technoweenie -I / HTTP/1.1 200 OK X-GitHub-Media-Type: github.v3

  157. ! curl https://api.github.com/users/technoweenie -I / HTTP/1.1 200 OK X-GitHub-Media-Type: github.v3

  158. ! curl https://api.github.com/users/technoweenie -I curl https://api.github.com/users/technoweenie -I \ -H "Accept:

    application/vnd.github.full+json" / HTTP/1.1 200 OK X-GitHub-Media-Type: github.v3 HTTP/1.1 200 OK X-GitHub-Media-Type: github.v3; param=full; format=json
  159. ! curl https://api.github.com/users/technoweenie -I curl https://api.github.com/users/technoweenie -I \ -H "Accept:

    application/vnd.github.full+json" / HTTP/1.1 200 OK X-GitHub-Media-Type: github.v3 HTTP/1.1 200 OK X-GitHub-Media-Type: github.v3; param=full; format=json
  160. ! curl https://api.github.com/users/technoweenie -I curl https://api.github.com/users/technoweenie -I \ -H "Accept:

    application/vnd.github.full+json" / HTTP/1.1 200 OK X-GitHub-Media-Type: github.v3 HTTP/1.1 200 OK X-GitHub-Media-Type: github.v3; param=full; format=json
  161. ! curl https://api.github.com/users/technoweenie -I curl https://api.github.com/users/technoweenie -I \ -H "Accept:

    application/vnd.github.full+json" / HTTP/1.1 200 OK X-GitHub-Media-Type: github.v3 HTTP/1.1 200 OK X-GitHub-Media-Type: github.v3; param=full; format=json curl https://api.github.com/users/technoweenie -I \ -H "Accept: application/vnd.github.beta.full+json" HTTP/1.1 200 OK X-GitHub-Media-Type: github.beta; param=full; format=json
  162. ! curl https://api.github.com/users/technoweenie -I curl https://api.github.com/users/technoweenie -I \ -H "Accept:

    application/vnd.github.full+json" / HTTP/1.1 200 OK X-GitHub-Media-Type: github.v3 HTTP/1.1 200 OK X-GitHub-Media-Type: github.v3; param=full; format=json curl https://api.github.com/users/technoweenie -I \ -H "Accept: application/vnd.github.beta.full+json" HTTP/1.1 200 OK X-GitHub-Media-Type: github.beta; param=full; format=json
  163. ! @jasonrudolph “Beta like you mean it.”

  164. ! Media types as beta code words.

  165. ! curl https://api.github.com/the/new/shiny / HTTP/1.1 415 Unsupported Media Type !

    { "message": "Unsupported 'Accept' header: github.v3." }
  166. ! curl https://api.github.com/the/new/shiny / HTTP/1.1 415 Unsupported Media Type !

    { "message": "Unsupported 'Accept' header: github.v3." }
  167. ! curl https://api.github.com/the/new/shiny / HTTP/1.1 415 Unsupported Media Type !

    { "message": "Unsupported 'Accept' header: github.v3." }
  168. ! curl https://api.github.com/the/new/shiny / HTTP/1.1 415 Unsupported Media Type !

    { "message": "Unsupported 'Accept' header: github.v3." } curl https://api.github.com/the/new/shiny \ -H "Accept: application/vnd.github.new-shiny-preview" HTTP/1.1 200 OK
  169. ! curl https://api.github.com/the/new/shiny / HTTP/1.1 415 Unsupported Media Type !

    { "message": "Unsupported 'Accept' header: github.v3." } curl https://api.github.com/the/new/shiny \ -H "Accept: application/vnd.github.new-shiny-preview" HTTP/1.1 200 OK
  170. ! curl https://api.github.com/the/new/shiny / HTTP/1.1 415 Unsupported Media Type !

    { "message": "Unsupported 'Accept' header: github.v3." } curl https://api.github.com/the/new/shiny \ -H "Accept: application/vnd.github.new-shiny-preview" HTTP/1.1 200 OK acknowledge API is a preview release
  171. ! Client testing

  172. None
  173. ! octokit.github.com

  174. ! github.com/octokit/octokit.rb

  175. ! github.com/rspec/rspec

  176. ! describe Octokit::Client do ! describe ".rate_limit" do it "makes

    a response if there is no last response" do client = Octokit::Client.new VCR.use_cassette "rate_limit" do rate = client.rate_limit ! expect(rate.limit).to be_kind_of Fixnum expect(rate.remaining).to be_kind_of Fixnum end end # .rate_limit end … -
  177. ! describe Octokit::Client do ! describe ".rate_limit" do it "makes

    a response if there is no last response" do client = Octokit::Client.new VCR.use_cassette "rate_limit" do rate = client.rate_limit ! expect(rate.limit).to be_kind_of Fixnum expect(rate.remaining).to be_kind_of Fixnum end end # .rate_limit end … -
  178. ! describe Octokit::Client do ! describe ".rate_limit" do it "makes

    a response if there is no last response" do client = Octokit::Client.new VCR.use_cassette "rate_limit" do rate = client.rate_limit ! expect(rate.limit).to be_kind_of Fixnum expect(rate.remaining).to be_kind_of Fixnum end end # .rate_limit end … -
  179. ! describe Octokit::Client do ! describe ".rate_limit" do it "makes

    a response if there is no last response" do client = Octokit::Client.new VCR.use_cassette "rate_limit" do rate = client.rate_limit ! expect(rate.limit).to be_kind_of Fixnum expect(rate.remaining).to be_kind_of Fixnum end end # .rate_limit end … -
  180. ! Record live API requests to play back later. Is

    it live, or is it Memorex®
  181. ! github.com/vcr/vcr

  182. ! Environment smoke tests

  183. ! / rm -rf spec/cassettes ❯ ~/.coral/repos/octokit.rb@octokit master ❯ ~/.coral/repos/octokit.rb@octokit

    master*
  184. ! / rm -rf spec/cassettes ❯ ~/.coral/repos/octokit.rb@octokit master OCTOKIT_API_ENDPOINT="https://rails5.github.com" rspec

    ❯ ~/.coral/repos/octokit.rb@octokit master* ................................................. ..............F.................................. ........................FFF...................... ...................F............................. ........F........................................ ................................................. .....................F........F..................
  185. ! measure change & minimize change ' handle change (

    At GitHub, we so we can in order to
  186. ! One more thing.

  187. ! github.com/interagent

  188. ! 410 Gone