Skip to content

Per-call options use an untyped dict[str, Any] with stringly-typed keys, against the SDK's own no-Any-in-public-API mandate #58

@OmarAlJarrah

Description

@OmarAlJarrah

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,124run(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:43options: 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestquestionFurther information is requested

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions