Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions gems/dexpace-core/lib/dexpace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,24 @@
require_relative "dexpace/auth/step"
require_relative "dexpace/auth/async_step"

# Phase 6b: the redirect layer, in dependency order -- after 6a's and 6c's blocks, because the
# step consults 6a's Resend (widened in place above with REDIR-6's predicate), 5b's logger and
# 4c's stages. The flat NotReplayableError first (filed under error/ beside RetryPredicateError,
# 6a's P6-56 precedent), then the two private resolution helpers, the public snapshot, the event
# and key vocabularies, the namespaced error, the three private per-call helpers -- the chain,
# the emitter and the re-issue builder -- and the pillar step last. `uri` is required by
# location.rb and step.rb, the files that name it; it was on the allowlist before phase 1.
require_relative "dexpace/error/not_replayable_error"
require_relative "dexpace/redirect/origin"
require_relative "dexpace/redirect/location"
require_relative "dexpace/redirect/condition_snapshot"
require_relative "dexpace/redirect/events"
require_relative "dexpace/redirect/scheme_downgrade_error"
require_relative "dexpace/redirect/chain"
require_relative "dexpace/redirect/emitter"
require_relative "dexpace/redirect/reissue"
require_relative "dexpace/redirect/step"

# The dexpace Ruby SDK: an HTTP-client toolkit, not an HTTP client.
#
# This file issues explicit `require_relative`s for the whole tree rather than using an
Expand Down
87 changes: 75 additions & 12 deletions gems/dexpace-core/lib/dexpace/async_pipeline.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
require_relative "pipeline"
require_relative "pipeline/cursor"
require_relative "pipeline/async_driver"
require_relative "pipeline/builder"
require_relative "pipeline/entry"
require_relative "pipeline/stages"
require_relative "instrumentation/async_step"
require_relative "instrumentation/http_logging"
require_relative "instrumentation/logger"
require_relative "resilience/async_retry_step"
require_relative "resilience/retry_settings"
require_relative "error/invalid_argument_error"

module Dexpace
Expand All @@ -33,15 +41,16 @@ module Dexpace
# **PIPE-32, and this paragraph IS the requirement's last clause discharged.** The async
# standard pipeline MUST NOT follow HTTP redirects at the pipeline layer -- there is no async
# redirect pillar step -- and "a port MUST document this asymmetry with the sync standard
# pipeline". The asymmetry: Pipeline.standard will install redirect + retry + instrumentation,
# pipeline". The asymmetry: Pipeline.standard installs redirect + retry + instrumentation,
# while AsyncPipeline.standard installs retry + instrumentation only and takes an explicit
# `redirect: :unsupported` argument so the absence is visible at the call site. Neither
# constructor exists yet (postponed to phase 6b, Task 13a), so PIPE-32's substantive clause
# holds vacuously in phase 4: there is no async standard pipeline to follow a redirect. What
# this phase deliberately does NOT do is make Stages::REDIRECT un-installable on the async
# path: PIPE-28 requires the identical staging policy in both runtimes, and a builder that
# rejected a REDIRECT step for #build_async and accepted it for #build would be two policies.
# PIPE-32 constrains the PRESET, not the runtime.
# `redirect: :unsupported` argument -- REQUIRED, and refusing every other value -- so the
# absence is visible at the call site. Both constructors exist since phase 6b's Task 13a, so
# the clause holds substantively: the async standard pipeline has no step at REDIRECT and a
# 3xx surfaces to the caller verbatim (REDIR-25). What phase 4c deliberately did NOT do, and
# phase 6b keeps, is make Stages::REDIRECT un-installable on the async path: PIPE-28 requires
# the identical staging policy in both runtimes, and a builder that rejected a REDIRECT step
# for #build_async and accepted it for #build would be two policies. PIPE-32 constrains the
# PRESET, not the runtime.
#
# PIPE-34's bridge is phase 2's Dexpace::AsyncTransport.sync_over(async_pipeline), not a method
# here (R13, P4-35): a built async pipeline answers #call(request, options, cancellation) with a
Expand All @@ -61,15 +70,69 @@ class AsyncPipeline

private_class_method :new

# PIPE-39's step-less shape, async form: PIPE-9's empty branch behind a name. The
# AsyncPipeline.standard constructor is postponed to phase 6 (6b, Task 13a) with its
# `redirect: :unsupported` argument; Builder#install_preset is the mechanism it will be
# written over.
# PIPE-39's step-less shape, async form: PIPE-9's empty branch behind a name. The second
# shape is .standard, below.
#
# @param transport [#call] an async transport
# @return [Dexpace::AsyncPipeline]
def self.direct(transport) = Pipeline::Builder.new(transport: transport).build_async

# PIPE-39's second named shape, the async standard pipeline: phase 6a's
# Resilience::AsyncRetryStep at RETRY and phase 5b's Instrumentation::AsyncStep at LOGGING,
# installed through Pipeline::Builder#install_preset and through nothing else (phase 4c's
# R14), and NO step at REDIRECT (PIPE-32, REDIR-25). `redirect:` is a required keyword that
# admits exactly `:unsupported`: the asymmetry with Pipeline.standard is spelled at the call
# site rather than left as an absence a reader has to notice (design §5.3). Written by phase
# 6b's Task 13a, the constructor phase 4c postponed.
#
# The keywords are Pipeline.standard's, minus a step for REDIRECT: `over` is an async
# transport or a Pipeline::Builder holding one (PIPE-24's empty-pillars rule applies to the
# second form); the retry step is built over `settings:`, `http_tracer_factory:` and
# `logger:`, the instrumentation step over `logger:`, `level:` and `preview_bytes:`. The
# async retry step waits through Dexpace::Async.delay, which needs a Fiber.scheduler: with
# none registered, a POSITIVE backoff fails the returned future with Dexpace::SeamError
# (never a blocking sleep), while a zero-length delay completes inline -- so a preset built
# with no scheduler retries only under settings whose delays are zero (6a's P6-54, R2's
# third route). This constructor takes no scheduler keyword because the step takes none.
#
# @param over [#call, Dexpace::Pipeline::Builder] an async transport, or a builder holding one
# @param redirect [Symbol] `:unsupported`, and nothing else
# @param settings [Dexpace::Resilience::RetrySettings] the retry step's configuration
# @param http_tracer_factory [#call, nil] the retry step's OBS-29 tracer factory
# @param logger [Dexpace::Instrumentation::Logger] shared by both steps
# @param level [Dexpace::Instrumentation::HTTPLogging] the instrumentation step's level
# @param preview_bytes [Integer, nil] the body-preview cap, required at the body level
# @return [Dexpace::AsyncPipeline]
# @raise [Dexpace::InvalidArgumentError] for any `redirect:` but `:unsupported` (PIPE-32), a
# transport that is not one, or a level whose requirements are unmet
# @raise [Dexpace::PipelineError] when RETRY or LOGGING of `over` is already occupied (PIPE-24)
def self.standard(over, redirect:, settings: Resilience::RetrySettings.build,
http_tracer_factory: nil, logger: Instrumentation::Logger::NULL,
level: Instrumentation::HTTPLogging::DEFAULT, preview_bytes: nil)
given = redirect #: untyped
unless given == :unsupported
raise InvalidArgumentError,
"AsyncPipeline.standard follows no redirects at the pipeline layer (PIPE-32); " \
"pass redirect: :unsupported, got #{given.inspect}"
end

retry_step = if http_tracer_factory.nil? # the family's own no-op default stays private
Resilience::AsyncRetryStep.build(settings: settings, logger: logger)
else
Resilience::AsyncRetryStep.build(settings: settings, logger: logger,
http_tracer_factory: http_tracer_factory,)
end
entries = [
Pipeline::Entry.build(stage: Pipeline::Stages::RETRY, step: retry_step),
Pipeline::Entry.build(stage: Pipeline::Stages::LOGGING,
step: Instrumentation::AsyncStep.build(
logger: logger, level: level, preview_bytes: preview_bytes,
),),
]
builder = over.is_a?(Pipeline::Builder) ? over : Pipeline::Builder.new(transport: over)
builder.install_preset(entries).build_async
end

# PIPE-31's terminal response-mapping operator (P4-38): a class method over the pivot rather
# than a #call-with-handler overload, because PIPE-26 requires #call to stay exactly the
# transport SPI's three positional parameters -- and because this way the four clauses are
Expand Down
24 changes: 24 additions & 0 deletions gems/dexpace-core/lib/dexpace/error/not_replayable_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "../error"

module Dexpace
# REDIR-6's clear error: a re-send site was asked to send a request whose body is present
# and not replayable, and refused rather than corrupt or truncate the re-send. Raised by
# Dexpace::Redirect::Step before a method-preserving redirect is followed (the 303 GET rebuild
# is exempt, having no body to re-send); the message names replayability by word, as the
# requirement asks. A StandardError including Dexpace::Error, flat under Dexpace:: on the
# tree's one-error-per-file convention and 6a's P6-56 precedent for this namespace
# (RetryPredicateError) -- the design named it Dexpace::Resilience::NotReplayableError, and
# phase 6b's checklist records the move.
class NotReplayableError < ::StandardError
include Dexpace::Error

# @param context [String] what was being re-sent, named in the message
def initialize(context)
super("#{context} cannot be re-sent: the request body is present and not replayable " \
"(REDIR-6)")
end
end
end
74 changes: 70 additions & 4 deletions gems/dexpace-core/lib/dexpace/pipeline.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
require_relative "http/request_options"
require_relative "cancellation"
require_relative "instrumentation/bundle"
require_relative "instrumentation/http_logging"
require_relative "instrumentation/logger"
require_relative "instrumentation/step"
require_relative "resilience/retry_settings"
require_relative "resilience/retry_step"
require_relative "redirect/step"
require_relative "error/invalid_argument_error"

module Dexpace
# The synchronous stage-based execution pipeline (design §5.1): sixteen totally ordered stages
Expand Down Expand Up @@ -58,15 +65,74 @@ class Pipeline
def self.builder(transport:) = Builder.new(transport: transport)

# PIPE-39's first named shape: a step-less pipeline that forwards directly to a transport --
# PIPE-9's empty pipeline behind a name a caller can find. The second, Pipeline.standard, is
# postponed to phase 6 (6b, Task 13a): the redirect and retry families are phase 6's and the
# instrumentation step is phase 5's, and a constructor named for defaults it cannot install
# is worse than its absence. Builder#install_preset is the mechanism it will be written over.
# PIPE-9's empty pipeline behind a name a caller can find. The second is .standard, below.
#
# @param transport [#call] the terminal hop
# @return [Dexpace::Pipeline]
def self.direct(transport) = Builder.new(transport: transport).build

# PIPE-39's second named shape, the sync standard pipeline: the default resilience pillars
# over a transport -- phase 6b's Redirect::Step at REDIRECT, phase 6a's Resilience::RetryStep
# at RETRY and phase 5b's Instrumentation::Step at LOGGING -- installed through
# Builder#install_preset and through nothing else (phase 4c's R14, P4-34: one installation
# path, and never a preset that claims defaults it does not install). Written by phase 6b's
# Task 13a, the constructor phase 4c postponed until all three families existed.
#
# `over` is a transport, or a Pipeline::Builder already holding one and possibly other steps:
# PIPE-24 is what the second form is for -- the preset installs into EMPTY pillars only, and
# a builder whose REDIRECT, RETRY or LOGGING pillar is already occupied rejects the whole call
# with nothing installed, while a builder holding steps at other stages keeps them around the
# preset's. `redirect:` takes a Redirect::Step configured by the caller, or nil for one built
# over `logger:`; the retry step is built over `settings:`, `http_tracer_factory:` and
# `logger:`, and the instrumentation step over `logger:`, `level:` and `preview_bytes:`
# (required at HTTPLogging::BODY, as Instrumentation::Step.build requires it). The async
# counterpart, AsyncPipeline.standard, installs no redirect step and takes an explicit
# `redirect: :unsupported` so PIPE-32's asymmetry is visible at the call site.
#
# @param over [#call, Dexpace::Pipeline::Builder] the terminal hop, or a builder holding it
# @param redirect [Dexpace::Redirect::Step, nil] the redirect step; built over `logger:`
# when nil
# @param settings [Dexpace::Resilience::RetrySettings] the retry step's configuration
# @param http_tracer_factory [#call, nil] the retry step's OBS-29 tracer factory; the
# step's own no-op default when nil
# @param logger [Dexpace::Instrumentation::Logger] shared by the three steps
# @param level [Dexpace::Instrumentation::HTTPLogging] the instrumentation step's level
# @param preview_bytes [Integer, nil] the body-preview cap, required at the body level
# @return [Dexpace::Pipeline]
# @raise [Dexpace::PipelineError] when a target pillar of `over` is already occupied (PIPE-24)
# @raise [Dexpace::InvalidArgumentError] for a `redirect:` that is not a Redirect::Step, a
# transport that is not one, or a level whose requirements are unmet
def self.standard(over, redirect: nil, settings: Resilience::RetrySettings.build,
http_tracer_factory: nil, logger: Instrumentation::Logger::NULL,
level: Instrumentation::HTTPLogging::DEFAULT, preview_bytes: nil)
retry_step = if http_tracer_factory.nil? # the family's own no-op default stays private
Resilience::RetryStep.build(settings: settings, logger: logger)
else
Resilience::RetryStep.build(settings: settings, logger: logger,
http_tracer_factory: http_tracer_factory,)
end
entries = [
Entry.build(stage: Stages::REDIRECT, step: standard_redirect(redirect, logger)),
Entry.build(stage: Stages::RETRY, step: retry_step),
Entry.build(stage: Stages::LOGGING,
step: Instrumentation::Step.build(logger: logger, level: level,
preview_bytes: preview_bytes,),),
]
builder = over.is_a?(Builder) ? over : Builder.new(transport: over)
builder.install_preset(entries).build
end

# The preset's redirect step: the caller's, or one built over the preset's logger.
def self.standard_redirect(redirect, logger)
return Redirect::Step.build(logger: logger) if redirect.nil?
return redirect if redirect.is_a?(Redirect::Step)

raise InvalidArgumentError,
"redirect: takes a Dexpace::Redirect::Step or nil on the sync standard pipeline, " \
"got #{redirect.inspect}"
end
private_class_method :standard_redirect

# Only Builder reaches this: `new` is private, and driver_class: is one of the two
# private_constant drivers, which resolve unqualified inside Builder because it is nested here
# (plan open question 9).
Expand Down
7 changes: 3 additions & 4 deletions gems/dexpace-core/lib/dexpace/pipeline/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,9 @@ def reload(entries)
# which is also why a preset that collides with ITSELF on a pillar is rejected. The same
# object already occupying a pillar is not a collision (PIPE-6) and is not re-installed.
#
# R14 / P4-34: this is the mechanism, and there is no standard step SET behind it. The
# redirect and retry families are phase 6's and the instrumentation step is phase 5's, so
# Pipeline.standard is postponed to phase 6 (6b, Task 13a) rather than shipping a
# constructor named for defaults it cannot install; phase 6 writes it OVER this method.
# R14 / P4-34: this is the mechanism, and Pipeline.standard and AsyncPipeline.standard --
# written by phase 6b's Task 13a once the redirect, retry and instrumentation families all
# existed -- are the two step SETS written OVER it, with no second installation path.
#
# @param entries [Array<Dexpace::Pipeline::Entry>]
# @return [self]
Expand Down
42 changes: 42 additions & 0 deletions gems/dexpace-core/lib/dexpace/redirect/chain.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "../http/url"
require_relative "origin"

module Dexpace
module Redirect
# The per-operation locals of one Step#call (PIPE-11: on the stack, never on the step): the
# seed request's origin triple and method, the current request, the count of redirects
# followed, and the visited set -- REDIR-16's, seeded with the seed's URL and grown by one
# external form per followed hop, so it is also REDIR-20's "visited URIs including the
# current request's". The seed's METHOD is kept beside its origin because REDIR-3 and
# REDIR-4 judge "the ORIGINAL request method": after a 303 GET rebuild the chain continues
# under GET, which the default set admits, while a seed POST does not.
#
# A private_constant with a sig/ mirror, asserted through Step's suite; not public API.
class Chain
attr_reader :request, :count, :visited, :seed, :seed_method

def initialize(request)
@request = request
@seed = Origin.of(request.url)
@seed_method = request.method
@visited = ::Set[URL.external_form(request.url)]
@count = 0
end

# REDIR-8: the current hop against the SEED origin; false on the seed's own drive.
def cross_origin? = Origin.cross?(@seed, @request.url)

# One hop followed: the target joins the visited set, the follow-up becomes the current
# request, the count goes up.
def advance!(follow_up)
@visited << URL.external_form(follow_up.url)
@request = follow_up
@count += 1
end
end
private_constant :Chain
end
end
Loading
Loading