Current design
Per-call overrides flow through the pipeline as untyped keyword arguments. Pipeline.run (and AsyncPipeline.run) accept **options: Any and stash them on the mutable scratchpad:
packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/pipeline.py:108,124 — run(self, request, dispatch, **options: Any) then PipelineContext(call=request_ctx, options=dict(options))
packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/async_pipeline.py:97,100 — same shape
packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/context.py:43 — options: dict[str, Any] = field(default_factory=dict)
Each policy then reads its knobs out of that dict by string key, with the default supplied inline at every read site:
- Retry:
policies/retry.py:279-285 reads retry_total, retry_connect, retry_read, retry_status, retry_backoff_factor, retry_backoff_max, timeout
- Logging:
policies/logging_policy.py:45 reads logging_enabled
- Tracing:
policies/tracing_policy.py:115,169 reads tracing_enabled
- Auth HTTPS enforcement:
http/auth/policies.py:259,426 reads enforce_https
The vocabulary is described only in prose (docs/pipelines.md:87-96, "Per-call opt-outs follow a convention"), and there is no central registry, no typed schema, and no validation of unknown keys.
Trade-off / concern
This is the one spot where the project's type contract evaporates, and it does so on the surface a user is most likely to fumble. The consequences:
- No feedback on misspelled or unsupported keys.
run(retry_totl=3) (a typo) type-checks fine, runs fine, and silently no-ops — the retry policy just falls back to its constructor default. mypy --strict cannot help because the parameter is **options: Any.
- The key-to-consumer coupling is invisible to tooling. Renaming
retry_total flags no caller; there is no symbol to "find usages" on. The contract lives in string literals scattered across policies plus a prose paragraph in the docs.
- Undiscoverable surface. A caller can only learn the option vocabulary by reading every policy or the convention note; there is no signature, dataclass, or enum to inspect.
- The auth security boundary lives in this same untyped scratchpad.
BearerTokenPolicy/BasicAuthPolicy gate non-HTTPS credential stamping on ctx.options.get("enforce_https", True) (http/auth/policies.py:259,426). A caller who writes enforse_https=False (typo) does not disable enforcement as intended, but more importantly the entire on/off switch for a security check is a magic string with no compile-time anchor.
For a toolkit whose stated values are "mypy --strict clean", "no Any in public API", and "typed public signatures" (CLAUDE.md), the primary per-call configuration path being dict[str, Any] keyed by string literals is an internal inconsistency worth resolving.
Proposed direction
Introduce a frozen options dataclass passed as a single typed parameter, with an explicit escape hatch for third-party policies:
@dataclass(frozen=True, slots=True)
class RequestOptions:
retry_total: int | None = None
retry_connect: int | None = None
retry_read: int | None = None
retry_status: int | None = None
retry_backoff_factor: float | None = None
retry_backoff_max: float | None = None
timeout: float | None = None
logging_enabled: bool = True
tracing_enabled: bool = True
enforce_https: bool = True
extras: Mapping[str, object] = field(default_factory=dict)
run(request, dispatch, options: RequestOptions | None = None) would carry the typed object; built-in policies read typed fields (options.retry_total, options.enforce_https), and extras remains for genuine open extension by third-party policies. None on a built-in field means "fall back to the policy's constructor default", preserving today's merge semantics (e.g. per-call retry_total=3 over an instance built with total_retries=0 still buffers — docs/pipelines.md:113-118).
Benefits: the built-in surface most users touch becomes fully typed and mypy --strict-covered; renames are caught by tooling; the option vocabulary is discoverable from one dataclass; and unknown built-in keys become impossible rather than silent no-ops.
Trade-offs to weigh honestly:
- A fixed dataclass is less open than a free dict, so third-party policies still read from
extras, which is untyped by nature. The improvement is bounded to the built-in surface — but that is exactly the surface the no-Any mandate is about.
- Changing
run's signature is a breaking change. A transition could keep **options as a deprecated alternative that constructs a RequestOptions (with a warning on unknown keys) for one release.
Acknowledging the current rationale
The design is deliberate and documented. context.py notes it is "Modelled on Azure's corehttp.runtime.pipeline.PipelineContext but without the dict-subclass gymnastics: options and data are plain dicts on a slotted dataclass," and docs/pipelines.md:87-96 documents the convention. Plain dicts are the idiomatic corehttp choice and keep the pipeline trivially open to third-party knobs.
Two things make revisiting worthwhile anyway. First, this SDK has explicitly higher type-discipline goals than corehttp — "no Any in public API" is a stated, enforced rule, so the corehttp precedent does not automatically transfer. Second, unlike the genuinely deferred items, the untyped-options choice is not listed among the CHANGELOG "Honest scope boundaries"; it reads as an inherited default rather than a trade-off that was weighed against the no-Any mandate. A typed object with an extras hatch keeps the openness for third parties while restoring the type contract on the surface the project most cares about. Worth at least a maintainer decision on record.
Current design
Per-call overrides flow through the pipeline as untyped keyword arguments.
Pipeline.run(andAsyncPipeline.run) accept**options: Anyand stash them on the mutable scratchpad:packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/pipeline.py:108,124—run(self, request, dispatch, **options: Any)thenPipelineContext(call=request_ctx, options=dict(options))packages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/async_pipeline.py:97,100— same shapepackages/dexpace-sdk-core/src/dexpace/sdk/core/pipeline/context.py:43—options: dict[str, Any] = field(default_factory=dict)Each policy then reads its knobs out of that dict by string key, with the default supplied inline at every read site:
policies/retry.py:279-285readsretry_total,retry_connect,retry_read,retry_status,retry_backoff_factor,retry_backoff_max,timeoutpolicies/logging_policy.py:45readslogging_enabledpolicies/tracing_policy.py:115,169readstracing_enabledhttp/auth/policies.py:259,426readsenforce_httpsThe vocabulary is described only in prose (
docs/pipelines.md:87-96, "Per-call opt-outs follow a convention"), and there is no central registry, no typed schema, and no validation of unknown keys.Trade-off / concern
This is the one spot where the project's type contract evaporates, and it does so on the surface a user is most likely to fumble. The consequences:
run(retry_totl=3)(a typo) type-checks fine, runs fine, and silently no-ops — the retry policy just falls back to its constructor default.mypy --strictcannot help because the parameter is**options: Any.retry_totalflags no caller; there is no symbol to "find usages" on. The contract lives in string literals scattered across policies plus a prose paragraph in the docs.BearerTokenPolicy/BasicAuthPolicygate non-HTTPS credential stamping onctx.options.get("enforce_https", True)(http/auth/policies.py:259,426). A caller who writesenforse_https=False(typo) does not disable enforcement as intended, but more importantly the entire on/off switch for a security check is a magic string with no compile-time anchor.For a toolkit whose stated values are "mypy --strict clean", "no Any in public API", and "typed public signatures" (CLAUDE.md), the primary per-call configuration path being
dict[str, Any]keyed by string literals is an internal inconsistency worth resolving.Proposed direction
Introduce a frozen options dataclass passed as a single typed parameter, with an explicit escape hatch for third-party policies:
run(request, dispatch, options: RequestOptions | None = None)would carry the typed object; built-in policies read typed fields (options.retry_total,options.enforce_https), andextrasremains for genuine open extension by third-party policies.Noneon a built-in field means "fall back to the policy's constructor default", preserving today's merge semantics (e.g. per-callretry_total=3over an instance built withtotal_retries=0still buffers —docs/pipelines.md:113-118).Benefits: the built-in surface most users touch becomes fully typed and
mypy --strict-covered; renames are caught by tooling; the option vocabulary is discoverable from one dataclass; and unknown built-in keys become impossible rather than silent no-ops.Trade-offs to weigh honestly:
extras, which is untyped by nature. The improvement is bounded to the built-in surface — but that is exactly the surface the no-Any mandate is about.run's signature is a breaking change. A transition could keep**optionsas a deprecated alternative that constructs aRequestOptions(with a warning on unknown keys) for one release.Acknowledging the current rationale
The design is deliberate and documented.
context.pynotes it is "Modelled on Azure'scorehttp.runtime.pipeline.PipelineContextbut without the dict-subclass gymnastics:optionsanddataare plain dicts on a slotted dataclass," anddocs/pipelines.md:87-96documents the convention. Plain dicts are the idiomatic corehttp choice and keep the pipeline trivially open to third-party knobs.Two things make revisiting worthwhile anyway. First, this SDK has explicitly higher type-discipline goals than corehttp — "no Any in public API" is a stated, enforced rule, so the corehttp precedent does not automatically transfer. Second, unlike the genuinely deferred items, the untyped-options choice is not listed among the CHANGELOG "Honest scope boundaries"; it reads as an inherited default rather than a trade-off that was weighed against the no-Any mandate. A typed object with an
extrashatch keeps the openness for third parties while restoring the type contract on the surface the project most cares about. Worth at least a maintainer decision on record.