From cfc17939f09ece0217ea7683991e509c30491e96 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 13 May 2025 14:13:53 +0000 Subject: [PATCH 1/4] chore(internal): version bump --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index d43a27af..aff43b26 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,7 +11,7 @@ GIT PATH remote: . specs: - orb-billing (0.3.2) + orb-billing (0.4.0) connection_pool GEM From 79f9994e2a77d786a7e1ffaa52c56916835b9b5f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 14 May 2025 21:32:55 +0000 Subject: [PATCH 2/4] feat: bump default connection pool size limit to minimum of 99 --- lib/orb/client.rb | 8 ++++---- lib/orb/internal/transport/pooled_net_requester.rb | 4 +++- rbi/orb/internal/transport/pooled_net_requester.rbi | 6 +++++- sig/orb/internal/transport/pooled_net_requester.rbs | 2 ++ 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/orb/client.rb b/lib/orb/client.rb index 4ae96ecd..2c8f3b9a 100644 --- a/lib/orb/client.rb +++ b/lib/orb/client.rb @@ -91,10 +91,10 @@ class Client < Orb::Internal::Transport::BaseClient def initialize( api_key: ENV["ORB_API_KEY"], base_url: ENV["ORB_BASE_URL"], - max_retries: Orb::Client::DEFAULT_MAX_RETRIES, - timeout: Orb::Client::DEFAULT_TIMEOUT_IN_SECONDS, - initial_retry_delay: Orb::Client::DEFAULT_INITIAL_RETRY_DELAY, - max_retry_delay: Orb::Client::DEFAULT_MAX_RETRY_DELAY, + max_retries: self.class::DEFAULT_MAX_RETRIES, + timeout: self.class::DEFAULT_TIMEOUT_IN_SECONDS, + initial_retry_delay: self.class::DEFAULT_INITIAL_RETRY_DELAY, + max_retry_delay: self.class::DEFAULT_MAX_RETRY_DELAY, idempotency_header: "Idempotency-Key" ) base_url ||= "https://api.withorb.com/v1" diff --git a/lib/orb/internal/transport/pooled_net_requester.rb b/lib/orb/internal/transport/pooled_net_requester.rb index d1c69f54..3da5ae62 100644 --- a/lib/orb/internal/transport/pooled_net_requester.rb +++ b/lib/orb/internal/transport/pooled_net_requester.rb @@ -11,6 +11,8 @@ class PooledNetRequester # https://github.com/golang/go/blob/c8eced8580028328fde7c03cbfcb720ce15b2358/src/net/http/transport.go#L49 KEEP_ALIVE_TIMEOUT = 30 + DEFAULT_MAX_CONNECTIONS = [Etc.nprocessors, 99].max + class << self # @api private # @@ -184,7 +186,7 @@ def execute(request) # @api private # # @param size [Integer] - def initialize(size: Etc.nprocessors) + def initialize(size: self.class::DEFAULT_MAX_CONNECTIONS) @mutex = Mutex.new @size = size @pools = {} diff --git a/rbi/orb/internal/transport/pooled_net_requester.rbi b/rbi/orb/internal/transport/pooled_net_requester.rbi index 36283844..9467525b 100644 --- a/rbi/orb/internal/transport/pooled_net_requester.rbi +++ b/rbi/orb/internal/transport/pooled_net_requester.rbi @@ -22,6 +22,8 @@ module Orb # https://github.com/golang/go/blob/c8eced8580028328fde7c03cbfcb720ce15b2358/src/net/http/transport.go#L49 KEEP_ALIVE_TIMEOUT = 30 + DEFAULT_MAX_CONNECTIONS = T.let(T.unsafe(nil), Integer) + class << self # @api private sig { params(url: URI::Generic).returns(Net::HTTP) } @@ -66,7 +68,9 @@ module Orb # @api private sig { params(size: Integer).returns(T.attached_class) } - def self.new(size: Etc.nprocessors) + def self.new( + size: Orb::Internal::Transport::PooledNetRequester::DEFAULT_MAX_CONNECTIONS + ) end end end diff --git a/sig/orb/internal/transport/pooled_net_requester.rbs b/sig/orb/internal/transport/pooled_net_requester.rbs index b0d97f8a..c96908d8 100644 --- a/sig/orb/internal/transport/pooled_net_requester.rbs +++ b/sig/orb/internal/transport/pooled_net_requester.rbs @@ -15,6 +15,8 @@ module Orb KEEP_ALIVE_TIMEOUT: 30 + DEFAULT_MAX_CONNECTIONS: Integer + def self.connect: (URI::Generic url) -> top def self.calibrate_socket_timeout: (top conn, Float deadline) -> void From ac8a45d8765183d41fbeaf23d1bd03c372908b45 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 14 May 2025 21:39:30 +0000 Subject: [PATCH 3/4] docs: rewrite much of README.md for readability --- README.md | 148 ++++++++++++++++++---------- lib/orb/internal/type/base_model.rb | 8 ++ 2 files changed, 102 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index c44d032a..44326f28 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Orb Ruby API library -The Orb Ruby library provides convenient access to the Orb REST API from any Ruby 3.2.0+ application. +The Orb Ruby library provides convenient access to the Orb REST API from any Ruby 3.2.0+ application. It ships with comprehensive types & docstrings in Yard, RBS, and RBI – [see below](https://github.com/orbcorp/orb-ruby#Sorbet) for usage with Sorbet. The standard library's `net/http` is used as the HTTP transport, with connection pooling via the `connection_pool` gem. ## Documentation @@ -35,16 +35,6 @@ customer = orb.customers.create(email: "example-customer@withorb.com", name: "My puts(customer.id) ``` -## Sorbet - -This library is written with [Sorbet type definitions](https://sorbet.org/docs/rbi). However, there is no runtime dependency on the `sorbet-runtime`. - -When using sorbet, it is recommended to use model classes as below. This provides stronger type checking and tooling integration. - -```ruby -orb.customers.create(email: "example-customer@withorb.com", name: "My Customer") -``` - ### Pagination List methods in the Orb API are paginated. @@ -64,15 +54,30 @@ page.auto_paging_each do |coupon| end ``` -### Errors +Alternatively, you can use the `#next_page?` and `#next_page` methods for more granular control working with pages. + +```ruby +if page.next_page? + new_page = page.next_page + puts(new_page.data[0].id) +end +``` + +### Handling errors When the library is unable to connect to the API, or if the API returns a non-success status code (i.e., 4xx or 5xx response), a subclass of `Orb::Errors::APIError` will be thrown: ```ruby begin customer = orb.customers.create(email: "example-customer@withorb.com", name: "My Customer") -rescue Orb::Errors::APIError => e - puts(e.status) # 400 +rescue Orb::Errors::APIConnectionError => e + puts("The server could not be reached") + puts(e.cause) # an underlying Exception, likely raised within `net/http` +rescue Orb::Errors::RateLimitError => e + puts("A 429 status code was received; we should back off a bit.") +rescue Orb::Errors::APIStatusError => e + puts("Another non-200-range status code was received") + puts(e.status) end ``` @@ -116,11 +121,7 @@ orb.customers.create( ### Timeouts -By default, requests will time out after 60 seconds. - -Timeouts are applied separately to the initial connection and the overall request time, so in some cases a request could wait 2\*timeout seconds before it fails. - -You can use the `timeout` option to configure or disable this: +By default, requests will time out after 60 seconds. You can use the timeout option to configure or disable this: ```ruby # Configure the default for all requests: @@ -136,40 +137,54 @@ orb.customers.create( ) ``` -## Model DSL +On timeout, `Orb::Errors::APITimeoutError` is raised. -This library uses a simple DSL to represent request parameters and response shapes in `lib/orb/models`. +Note that requests that time out are retried by default. -With the right [editor plugins](https://shopify.github.io/ruby-lsp), you can ctrl-click on elements of the DSL to navigate around and explore the library. +## Advanced concepts -In all places where a `BaseModel` type is specified, vanilla Ruby `Hash` can also be used. For example, the following are interchangeable as arguments: +### BaseModel -```ruby -# This has tooling readability, for auto-completion, static analysis, and goto definition with supported language services -params = Orb::Models::CustomerCreateParams.new(email: "example-customer@withorb.com", name: "My Customer") +All parameter and response objects inherit from `Orb::Internal::Type::BaseModel`, which provides several conveniences, including: -# This also works -params = { - email: "example-customer@withorb.com", - name: "My Customer" -} -``` +1. All fields, including unknown ones, are accessible with `obj[:prop]` syntax, and can be destructured with `obj => {prop: prop}` or pattern-matching syntax. -## Editor support +2. Structural equivalence for equality; if two API calls return the same values, comparing the responses with == will return true. -A combination of [Shopify LSP](https://shopify.github.io/ruby-lsp) and [Solargraph](https://solargraph.org/) is recommended for non-[Sorbet](https://sorbet.org) users. The former is especially good at go to definition, while the latter has much better auto-completion support. +3. Both instances and the classes themselves can be pretty-printed. -## Advanced concepts +4. Helpers such as `#to_h`, `#deep_to_h`, `#to_json`, and `#to_yaml`. + +### Making custom or undocumented requests + +#### Undocumented properties -### Making custom/undocumented requests +You can send undocumented parameters to any endpoint, and read undocumented response properties, like so: + +Note: the `extra_` parameters of the same name overrides the documented parameters. + +```ruby +customer = + orb.customers.create( + email: "example-customer@withorb.com", + name: "My Customer", + request_options: { + extra_query: {my_query_parameter: value}, + extra_body: {my_body_parameter: value}, + extra_headers: {"my-header": value} + } + ) + +puts(customer[:my_undocumented_property]) +``` #### Undocumented request params -If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` under the `request_options:` parameter when making a requests as seen in examples above. +If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` under the `request_options:` parameter when making a request as seen in examples above. #### Undocumented endpoints -To make requests to undocumented endpoints, you can make requests using `client.request`. Options on the client will be respected (such as retries) when making this request. +To make requests to undocumented endpoints while retaining the benefit of auth, retries, and so on, you can make requests using `client.request`, like so: ```ruby response = client.request( @@ -177,42 +192,67 @@ response = client.request( path: '/undocumented/endpoint', query: {"dog": "woof"}, headers: {"useful-header": "interesting-value"}, - body: {"he": "llo"}, + body: {"hello": "world"} ) ``` ### Concurrency & connection pooling -The `Orb::Client` instances are thread-safe, and should be re-used across multiple threads. By default, each `Client` have their own HTTP connection pool, with a maximum number of connections equal to thread count. +The `Orb::Client` instances are threadsafe, but only are fork-safe when there are no in-flight HTTP requests. + +Each instance of `Orb::Client` has its own HTTP connection pool with a default size of 99. As such, we recommend instantiating the client once per application in most settings. -When the maximum number of connections has been checked out from the connection pool, the `Client` will wait for an in use connection to become available. The queue time for this mechanism is accounted for by the per-request timeout. +When all available connections from the pool are checked out, requests wait for a new connection to become available, with queue time counting towards the request timeout. Unless otherwise specified, other classes in the SDK do not have locks protecting their underlying data structure. -Currently, `Orb::Client` instances are only fork-safe if there are no in-flight HTTP requests. +## Sorbet -### Sorbet +This library provides comprehensive [RBI](https://sorbet.org/docs/rbi) definitions, and has no dependency on sorbet-runtime. -#### Enums +You can provide typesafe request parameters like so: -Sorbet's typed enums require sub-classing of the [`T::Enum` class](https://sorbet.org/docs/tenum) from the `sorbet-runtime` gem. +```ruby +orb.customers.create(email: "example-customer@withorb.com", name: "My Customer") +``` -Since this library does not depend on `sorbet-runtime`, it uses a [`T.all` intersection type](https://sorbet.org/docs/intersection-types) with a ruby primitive type to construct a "tagged alias" instead. +Or, equivalently: ```ruby -module Orb::BillingCycleRelativeDate - # This alias aids language service driven navigation. - TaggedSymbol = T.type_alias { T.all(Symbol, Orb::BillingCycleRelativeDate) } -end +# Hashes work, but are not typesafe: +orb.customers.create(email: "example-customer@withorb.com", name: "My Customer") + +# You can also splat a full Params class: +params = Orb::CustomerCreateParams.new(email: "example-customer@withorb.com", name: "My Customer") +orb.customers.create(**params) ``` -#### Argument passing trick +### Enums -It is possible to pass a compatible model / parameter class to a method that expects keyword arguments by using the `**` splat operator. +Since this library does not depend on `sorbet-runtime`, it cannot provide [`T::Enum`](https://sorbet.org/docs/tenum) instances. Instead, we provide "tagged symbols" instead, which is always a primitive at runtime: ```ruby -params = Orb::Models::CustomerCreateParams.new(email: "example-customer@withorb.com", name: "My Customer") -orb.customers.create(**params) +# :duplicate +puts(Orb::CreditNoteCreateParams::Reason::DUPLICATE) + +# Revealed type: `T.all(Orb::CreditNoteCreateParams::Reason, Symbol)` +T.reveal_type(Orb::CreditNoteCreateParams::Reason::DUPLICATE) +``` + +Enum parameters have a "relaxed" type, so you can either pass in enum constants or their literal value: + +```ruby +# Using the enum constants preserves the tagged type information: +orb.credit_notes.create( + reason: Orb::CreditNoteCreateParams::Reason::DUPLICATE, + # … +) + +# Literal values is also permissible: +orb.credit_notes.create( + reason: :duplicate, + # … +) ``` ## Versioning diff --git a/lib/orb/internal/type/base_model.rb b/lib/orb/internal/type/base_model.rb index 86b1a50c..e0776129 100644 --- a/lib/orb/internal/type/base_model.rb +++ b/lib/orb/internal/type/base_model.rb @@ -386,6 +386,14 @@ def deep_to_h = self.class.recursively_to_h(@data, convert: false) # @param keys [Array, nil] # # @return [Hash{Symbol=>Object}] + # + # @example + # # `amount_discount` is a `Orb::AmountDiscount` + # amount_discount => { + # amount_discount: amount_discount, + # applies_to_price_ids: applies_to_price_ids, + # discount_type: discount_type + # } def deconstruct_keys(keys) (keys || self.class.known_fields.keys) .filter_map do |k| From 1336d4180bf1f1bef0f90d37e892f24c21d349c7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 14 May 2025 21:39:54 +0000 Subject: [PATCH 4/4] release: 0.5.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 18 ++++++++++++++++++ README.md | 2 +- lib/orb/version.rb | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index da59f99e..2aca35ae 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.4.0" + ".": "0.5.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c10a7c25..97f8c909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.5.0 (2025-05-14) + +Full Changelog: [v0.4.0...v0.5.0](https://github.com/orbcorp/orb-ruby/compare/v0.4.0...v0.5.0) + +### Features + +* bump default connection pool size limit to minimum of 99 ([79f9994](https://github.com/orbcorp/orb-ruby/commit/79f9994e2a77d786a7e1ffaa52c56916835b9b5f)) + + +### Chores + +* **internal:** version bump ([cfc1793](https://github.com/orbcorp/orb-ruby/commit/cfc17939f09ece0217ea7683991e509c30491e96)) + + +### Documentation + +* rewrite much of README.md for readability ([ac8a45d](https://github.com/orbcorp/orb-ruby/commit/ac8a45d8765183d41fbeaf23d1bd03c372908b45)) + ## 0.4.0 (2025-05-13) Full Changelog: [v0.3.2...v0.4.0](https://github.com/orbcorp/orb-ruby/compare/v0.3.2...v0.4.0) diff --git a/README.md b/README.md index 44326f28..79585040 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ To use this gem, install via Bundler by adding the following to your application ```ruby -gem "orb-billing", "~> 0.4.0" +gem "orb-billing", "~> 0.5.0" ``` diff --git a/lib/orb/version.rb b/lib/orb/version.rb index 97389ae7..0d3cf36b 100644 --- a/lib/orb/version.rb +++ b/lib/orb/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Orb - VERSION = "0.4.0" + VERSION = "0.5.0" end