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 @@ -192,6 +192,24 @@
require_relative "dexpace/instrumentation/step"
require_relative "dexpace/instrumentation/async_step"

# Phase 6a: the retry layer, in dependency order -- after 5b's block, because the settings read
# the clock and the configuration slot (5a) and the steps emit through the logging facade (5b)
# and the HTTP-tracer vocabulary (5c). The one flat error first, then the shared policy core
# (the private parsers, the policy over them, the re-sendability gate, the settings), then the
# private helpers the two stage drivers share, the two drivers, and the recovery-stack engine.
# Three earlier files gained a method or a keyword in place and appear above: http_date.rb (the
# day group), error/protocol_error.rb (the baked flag) and pipeline/cursor.rb with the two
# runtimes and the two instrumentation steps (the context-bundle widening).
require_relative "dexpace/error/retry_predicate_error"
require_relative "dexpace/resilience/pacing_parsers"
require_relative "dexpace/resilience/policy"
require_relative "dexpace/resilience/resend"
require_relative "dexpace/resilience/retry_settings"
require_relative "dexpace/resilience/retry_step_helpers"
require_relative "dexpace/resilience/retry_step"
require_relative "dexpace/resilience/async_retry_step"
require_relative "dexpace/resilience/recovery_retry"

# 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
10 changes: 8 additions & 2 deletions gems/dexpace-core/lib/dexpace/async_pipeline.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require_relative "closeable"
require_relative "http/request_options"
require_relative "cancellation"
require_relative "instrumentation/bundle"
require_relative "async/future"
require_relative "pipeline"
require_relative "pipeline/cursor"
Expand Down Expand Up @@ -117,16 +118,21 @@ def initialize(entries:, transport:, driver_class:)
# is reached qualified because this class's cref does not include Dexpace::Pipeline; there is
# exactly one Cursor class and both runtimes use it (P4-30).
#
# `bundle:` is phase 6a's widening, as on Pipeline#call: the empty branch dispatches with
# no cursor and therefore carries no bundle, which is PIPE-9's own shape.
#
# @param request [Dexpace::Request]
# @param options [Dexpace::RequestOptions]
# @param cancellation [Dexpace::Cancellation]
# @param bundle [Dexpace::Instrumentation::Bundle] the per-call correlation bundle
# @return [Dexpace::Async::Future]
def call(request, options = RequestOptions::EMPTY, cancellation = Cancellation.none)
def call(request, options = RequestOptions::EMPTY, cancellation = Cancellation.none,
bundle: Instrumentation::Bundle::NONE)
driver = @driver_class.new(self)
return driver.dispatch(request, options, cancellation) if @entries.empty?

Pipeline::Cursor.build(drive: driver, request: request, options: options,
cancellation: cancellation,).call(request)
cancellation: cancellation, bundle: bundle,).call(request)
end
end
end
22 changes: 19 additions & 3 deletions gems/dexpace-core/lib/dexpace/error/protocol_error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require_relative "../error"
require_relative "invalid_argument_error"
require_relative "../http/response"
require_relative "../retryability"

module Dexpace
# XCUT-4's branch (a): a protocol error carries a fully-received response -- status, headers and
Expand All @@ -24,9 +25,14 @@ module Dexpace
# a customer identifier, and OBS-11..OBS-19's redaction is phase 5's. #response is how a caller
# who wants the body reads it (the plan's open question 5).
#
# No #retryable_by_status?. XCUT-5 requires the baked flag to be computed once at construction
# from a SINGLE shared status classifier, and that classifier is phase 5a's; phase 6a (Task 6)
# adds the predicate to this class. Adding a method widens a signature, which NFR-4 permits.
# #retryable_by_status? is XCUT-5's baked flag (RETRY-3), computed ONCE at construction from
# the SINGLE shared status classifier -- phase 5a's Dexpace::Retryability, which phase 4b
# could not build and phase 6a reads rather than rebuilds (its Task 6). It is deliberately
# NOT named #retryable?, the open capability XCUT-6's throwable query looks for (P6-10): a
# ProtocolError answering that query would let the baked set override the CONFIGURED set
# RETRY-37 makes authoritative whenever the error is wrapped in another -- including a set
# that deliberately narrows -- so the two questions carry two names, design §6.1's own, and
# the retry drivers consult Resilience::Policy.retry_eligible?(status, set:) and never this.
class ProtocolError < ::StandardError
include Dexpace::Error

Expand Down Expand Up @@ -73,9 +79,19 @@ def initialize(response)

@response = response
@status = response.status
@retryable_by_status = Retryability.retryable_status?(@status)
super(describe(@status))
end

# XCUT-5 / RETRY-3: whether the status is one the built-in classifier calls retryable,
# baked at construction and never per-subclass -- a queryable property of the error, and not
# what the retry step consults (XCUT-5's closing NOTE; see the class comment).
#
# @return [Boolean]
def retryable_by_status?
@retryable_by_status
end

private

def describe(status)
Expand Down
21 changes: 21 additions & 0 deletions gems/dexpace-core/lib/dexpace/error/retry_predicate_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "../error"

module Dexpace
# RETRY-40's well-typed abort: raised by the two stage-based retry drivers when a
# caller-supplied `should_retry:` predicate itself raises. The predicate's own failure is
# carried as #cause -- a genuine wrap of a fresh failure, raised with `cause:` at the site,
# and not a re-raise of a carried error, so pipeline/7ce4431d's `cause: nil` rule does not
# apply to it. A StandardError including Dexpace::Error, flat under Dexpace:: on the tree's
# one-error-per-file convention (PipelineError, ContextConflictError, ProtocolError), and
# numbered P6-11: RETRY-40 requires the well-typed error and names none.
#
# Only a predicate that raises a StandardError is wrapped. The fatal family -- NoMemoryError,
# SystemStackError -- propagates through an absent rescue arm, unchanged and unwrapped
# (RETRY-25, RETRY-40's "fatal errors rethrown unchanged").
class RetryPredicateError < ::StandardError
include Dexpace::Error
end
end
26 changes: 17 additions & 9 deletions gems/dexpace-core/lib/dexpace/http_date.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,26 @@ module HTTPDate
# Time.httpdate rejects a bare "06 Nov 1994 ..." too, so the port and the stdlib agree. No
# normalisation runs before the match, which is how CFG-31's blank-input failure is a
# property of the pattern rather than of whatever a normaliser did to the input. The timeout
# is per-pattern (never Regexp.timeout), on a fixed-width grammar that cannot backtrack, by
# the same rule phase 1 applied to a two-character hex pattern.
# is per-pattern (never Regexp.timeout), on a grammar whose one variable-width group is a
# bounded digit run that cannot backtrack, by the same rule phase 1 applied to a
# two-character hex pattern. The day group is (\d{1,2}), not (\d{2}): RETRY-15 requires the
# Retry-After HTTP-date form to be parsed "tolerant of an informational weekday and
# single-digit day", design §6.1 forbids a second parser, and phase 6a widened this group in
# place (its R1) -- "Sun, 6 Nov 1994 08:49:37 GMT" parses as 6 November. Nothing else moved:
# the weekday is still REQUIRED and still not validated (CFG-30's "informational only" and
# RETRY-15's "informational weekday" are one tolerance), and RFC 850, asctime, a leading
# space, a two-digit year and a trailing token all still fail (CFG-31).
GRAMMAR = ::Regexp.new(
'\A[A-Za-z]{3}, (\d{2}) ([A-Za-z]{3}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) ' \
'\A[A-Za-z]{3}, (\d{1,2}) ([A-Za-z]{3}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) ' \
'(GMT|UTC|\+0000|\+00:00)\z',
::Regexp::IGNORECASE,
timeout: 1.0,
).freeze

# Neither is public surface -- a month table and one pattern with a single reader each, and
# P5-1 enumerates HTTPDate alone. A bare reference from inside `module Dexpace; module
# HTTPDate` resolves whatever the visibility, so phase 6a widening the day group edits this
# file at no cost.
# HTTPDate` resolves whatever the visibility, which is what let phase 6a widen the day group
# above in place at no cost to the public surface.
private_constant :MONTHS, :GRAMMAR

# The canonical HTTP-date form, rendered in UTC with a literal GMT and a zero-padded day
Expand All @@ -67,10 +74,11 @@ def format(time)
time.getutc.httpdate
end

# The UTC instant an RFC 1123 date denotes, with CFG-30's four tolerances and CFG-31's
# strictness: the four zone tokens all mean the zero offset, month and zone case do not
# matter, the weekday is stripped and never compared against the date; blank input, a missing
# comma, RFC 850, asctime, a leading space, a single-digit day and a trailing token all fail.
# The UTC instant an RFC 1123 date denotes, with CFG-30's four tolerances, RETRY-15's
# single-digit day (phase 6a's widening) and CFG-31's strictness: the four zone tokens all
# mean the zero offset, month and zone case do not matter, the weekday is stripped and never
# compared against the date, a one-digit day is read as that day; blank input, a missing
# comma, an absent weekday, RFC 850, asctime, a leading space and a trailing token all fail.
# So does a syntactically well-formed but impossible date -- Time.utc silently normalises
# 31 November, 29 February 1995, hour 24 and second 60 into the next day, month, day or
# minute, so every component is checked against what Time.utc built rather than trusted.
Expand Down
5 changes: 3 additions & 2 deletions gems/dexpace-core/lib/dexpace/instrumentation/async_step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ def call(request, cursor)
# @return [Array] the future and the Pending the settlement side needs
def head(request, cursor)
started = @clock.monotonic
span = open_span(request)
scope = Tracing.correlate(span, Bundle::NONE)
bundle = cursor.bundle
span = open_span(request, bundle)
scope = Tracing.correlate(span, bundle)
pending = nil #: Pending?
begin
request = prepare(request)
Expand Down
4 changes: 3 additions & 1 deletion gems/dexpace-core/lib/dexpace/instrumentation/bundle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ module Instrumentation
# NONE.with (both validate), may add METHODS here and to TraceIdFlavour, may give the two
# no-op classes their protocols, and may not rename or remove a member, add one, store
# validity, change the sentinels, replace the flavour with a Symbol or hand this class a
# second NONE.
# second NONE. Phase 6a honoured the same obligation: it added no member and no method, and
# made a bundle reachable by a pipeline step through Pipeline::Cursor#bundle, seeded per
# call by the `bundle:` keyword on Pipeline#call and AsyncPipeline#call (its Task 8).
#
# trace_state is a list of pairs, not a Hash: OBS-26 says "a vendor trace-state list" and W3C
# tracestate is ordered, most recent vendor first, which a Hash would lose. trace_flags stays
Expand Down
15 changes: 11 additions & 4 deletions gems/dexpace-core/lib/dexpace/instrumentation/http_tracer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,17 @@ module Instrumentation
# SAME error object; and one tracer instance corresponds 1:1 to one logical operation
# lifecycle, created by its factory per operation. ordering_test.rb drives a conformant
# emitter through both a succeeding and a retry-exhausted operation, which is the
# requirement's own conformance clause. Nothing in phase 5 emits any of it -- the per-attempt
# group is phase 6a's retry step (its Task 9), the transport milestones are phase 8's, and
# the operation triple is on phase 10's inbound list -- which OBS-29's own last sentence
# anticipates: "pipeline/transport wiring to emit it is a follow-up" (R14).
# requirement's own conformance clause. The per-attempt group is emitted by phase 6a's three
# retry drivers (Resilience::RetryStep, ::AsyncRetryStep and ::RecoveryRetry), through the
# tracer their `http_tracer_factory:` produces once per operation -- called with the
# Pipeline::Cursor on the stage stack and with the Dexpace::Request on the recovery stack --
# and retries_exhausted fires there only when a RETRYABLE failure met a spent budget, never
# for a failure that was never retryable (ordering_test.rb's third case). The transport
# milestones and the operation-lifecycle triple are emitted by nothing in v1 (phase 10's
# inbound list; docs/first-release.md's behavioural-asymmetries entry), which OBS-29's own
# last sentence anticipates: "pipeline/transport wiring to emit it is a follow-up" (R14).
# The interface _HTTPTracer, which 5c deliberately did not declare, arrived with the wiring
# (6a's R3) in this file's sig/ mirror.
#
# OBS-30 binds every implementation: each callback is safe to invoke concurrently and from
# a transport thread other than the caller's, and never throws -- the runtime does not
Expand Down
40 changes: 24 additions & 16 deletions gems/dexpace-core/lib/dexpace/instrumentation/step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,21 @@ module Instrumentation
#
# The two slots are keywords defaulting to the constants the phase ships -- phase 4a's
# NO_TRACER_FACTORY and phase 5c's NO_METER (P5-33) -- and the two instruments are created
# once here, in .build, never per request (OBS-31). The reconciled precedence's first clause,
# "the request context's bundle when it is not Bundle::NONE", is not implementable in phase
# 5: no reader exists by which a pipeline step reaches a RequestContext or a Bundle (4c does
# not consume 4a; Request's members are method, url, headers and body; PIPE-11 forbids
# ambient carriage), so the step resolves to its keyword and to Bundle::NONE, which is
# OBS-34's and XCUT-19(e)'s own default configuration. Phase 6a's Task 8 owns the Cursor
# widening that closes it, at which point #open_span is the one method that changes. Until
# then the span is named by the request's method token, which is also what the tracer
# factory is asked for: the operation identifier is 4a's RequestContext#operation_name, out
# of reach for the same reason.
# once here, in .build, never per request (OBS-31). The reconciled precedence's three
# clauses -- "the request context's bundle when it is not Bundle::NONE, else the step's
# constructor keyword, else the constant" -- are honoured for the TRACER FACTORY since phase
# 6a's Task 8 widened Cursor with #bundle: #open_span reads the cursor's bundle and takes its
# tracer factory when the bundle is not Bundle::NONE, else this step's keyword (which is the
# constant when nothing was passed), and Tracing.correlate is handed the same bundle, so a
# populated one pushes its trace and span ids onto the diagnostic context (OBS-23). The
# meter has no bundle source: CTX-14's bundle carries no meter and the two instruments are
# created once at .build (OBS-31), so the meter's precedence is the keyword, then the
# constant, and that is the whole of it. A call that seeds no bundle resolves exactly as
# phase 5 did -- the keyword and Bundle::NONE, OBS-34's and XCUT-19(e)'s own default
# configuration. The span is still named by the request's method token, which is also what
# the tracer factory is asked for: the operation identifier is 4a's
# RequestContext#operation_name and the cursor's bundle carries no name (P6-51;
# docs/first-release.md's behavioural-asymmetries entry).
#
# The level guard is read once at the top of #call and consulted at three sites -- the two
# emissions and the body wrapping -- and a step that re-read it inside a helper would be one
Expand Down Expand Up @@ -124,8 +129,9 @@ def stage
# @return [Dexpace::Response] the response, its body wrapped at the body level
def call(request, cursor)
started = @clock.monotonic
span = open_span(request)
scope = Tracing.correlate(span, Bundle::NONE)
bundle = cursor.bundle
span = open_span(request, bundle)
scope = Tracing.correlate(span, bundle)
begin
request = prepare(request)
response = cursor.call(request)
Expand All @@ -144,11 +150,13 @@ def call(request, cursor)

private

# 4a's #tracer, then 5c's #start_span, both named by the method token until phase 6a's
# Task 8 makes the operation name reachable (see the class comment).
def open_span(request)
# 4a's #tracer, then 5c's #start_span, both named by the method token (see the class
# comment). The factory is the cursor's bundle's when the call seeded one, else this
# step's own: the reconciled precedence's first clause, live since phase 6a's Task 8.
def open_span(request, bundle)
name = request.method.to_s
@tracer_factory.tracer(name: name).start_span(name)
factory = bundle.equal?(Bundle::NONE) ? @tracer_factory : bundle.tracer_factory
factory.tracer(name: name).start_span(name)
end

# 5c's #finish and the two instruments, unwrapped. `attributes:` is optional on both
Expand Down
11 changes: 6 additions & 5 deletions gems/dexpace-core/lib/dexpace/instrumentation/tracing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,12 @@ def with_span(span)
# the push is skipped and activation delegates to plain current-span activation, in the
# requirement's own words. And for a bundle that is not #valid? it likewise delegates: an
# all-zero id "MUST be treated as invalid/no-trace" (OBS-26), and a recording span paired
# with Bundle::NONE is the only state 5b's step can reach in phase 5 (R11) -- without the
# guard every log event of a tracing-enabled client would carry trace.id=<32 zeros>, a
# fake trace and worse than an absent key. Bundle#valid? is 4a's derived predicate and is
# exactly this test; once phase 6a's Task 8 makes a populated bundle reachable, the guard
# stops firing. NO_SCOPE when the span and both key values are already in place.
# with Bundle::NONE was the only state 5b's step could reach in phase 5 (R11) -- without
# the guard every log event of a tracing-enabled client would carry trace.id=<32 zeros>,
# a fake trace and worse than an absent key. Bundle#valid? is 4a's derived predicate and
# is exactly this test; since phase 6a's Task 8 made a populated bundle reachable through
# Cursor#bundle, the guard fires only for a call that seeded none. NO_SCOPE when the span
# and both key values are already in place.
#
# @param span [Object] the _Span to make current
# @param bundle [Bundle] the correlation bundle whose ids are pushed
Expand Down
12 changes: 10 additions & 2 deletions gems/dexpace-core/lib/dexpace/pipeline.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require_relative "closeable"
require_relative "http/request_options"
require_relative "cancellation"
require_relative "instrumentation/bundle"

module Dexpace
# The synchronous stage-based execution pipeline (design §5.1): sixteen totally ordered stages
Expand Down Expand Up @@ -82,15 +83,22 @@ def initialize(entries:, transport:, driver_class:)
# holds with no cursor allocated at all. There is no reading under which a NON-empty pipeline
# may skip the cursor (R12).
#
# `bundle:` is phase 6a's widening (Task 8): the per-call instrumentation bundle the cursor
# carries to every step (Cursor#bundle), Bundle::NONE when omitted. An optional keyword
# beside the transport SPI's three positionals, which Transport.conforms? still accepts, so
# a Pipeline stays a Dexpace::Transport by the duck type (P4-38's reasoning, widened, P6-51).
#
# @param request [Dexpace::Request]
# @param options [Dexpace::RequestOptions] the caller's per-call options, threaded unchanged
# @param cancellation [Dexpace::Cancellation]
# @param bundle [Dexpace::Instrumentation::Bundle] the per-call correlation bundle
# @return [Dexpace::Response]
def call(request, options = RequestOptions::EMPTY, cancellation = Cancellation.none)
def call(request, options = RequestOptions::EMPTY, cancellation = Cancellation.none,
bundle: Instrumentation::Bundle::NONE)
return @transport.call(request, options, cancellation) if @entries.empty?

Cursor.build(drive: @driver_class.new(self), request: request, options: options,
cancellation: cancellation,).call(request)
cancellation: cancellation, bundle: bundle,).call(request)
end
end
end
Loading
Loading