Skip to content

refactor(airflow): give the library a canonical error type - #694

Open
jvanbuel wants to merge 2 commits into
mainfrom
refactor/airflow-error-type
Open

refactor(airflow): give the library a canonical error type#694
jvanbuel wants to merge 2 commits into
mainfrom
refactor/airflow-error-type

Conversation

@jvanbuel

@jvanbuel jvanbuel commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Rows 20–26 of the review: the flowrs-airflow library API pass.

The problem

Every public method returned anyhow::Result. A caller could not tell a connection failure from a 404 from a schema mismatch, and any consumer of the crate was forced onto anyhow (M-ERRORS-CANONICAL-STRUCTS, M-DONT-LEAK-TYPES).

What changed

A new AirflowError (thiserror) with the categories a caller can actually act on — InvalidUrl, Http, Status, Decode, Auth, Discovery, FeatureNotEnabled — plus a crate-level Result alias.

Three user-visible improvements fall out of it:

  1. Non-success responses now carry the response body. error_for_status() throws the body away, which is exactly where Airflow puts the detail explaining why a trigger/clear/mark was rejected. The error popup showed HTTP status client error (422 Unprocessable Entity) for url ... and nothing about why; it now shows Airflow's own message.
  2. Every endpoint reports parse failures with a body snippet. That handling existed for v1 only (parse_json_response); the response.json() calls everywhere else produced a bare serde message with no context. Both are now the shared read_json.
  3. The endpoint URL is parsed once at construction instead of on every request, so a bad endpoint is reported at connect time rather than on the first API call.

Encapsulation

  • BaseClient::client / ::config and V1Client::base / V2Client::base are now private (they were pub and leaked reqwest::Client; nothing outside the crate used them).
  • endpoint() returns the parsed &Url, which also lets the TUI's open-url builders drop their re-parse and its error path.
  • base_api is pub(crate) — it hands out a reqwest::RequestBuilder and was never an intended extension point.

Scope

The managed-service integrations keep anyhow internally for their layered .context() messages. The chain is flattened into the message at the public boundary (AirflowError::auth / ::discovery) so nothing is lost in the Display the TUI renders — the app only ever prints e.to_string(), and a #[source] chain would have been silently dropped there.

Two things deliberately still expose reqwest, and I think correctly: AuthProvider takes and returns a RequestBuilder (it is an HTTP-auth extension point; wrapping it would buy nothing), and AirflowError::Http holds a reqwest::Error as its source (preserving the real cause is the point).

The TUI's own trait layer keeps anyhow — that is the application choosing its error type, which is the whole point of the library no longer forcing one.

Tests

Four new tests, each covering a behaviour that actually changed and would fail against a plausible wrong implementation:

  • a Status error surfaces the response body
  • an empty body falls back to the status' canonical reason rather than rendering a trailing bare colon
  • BaseClient::new rejects an unusable endpoint at construction
  • and accepts a valid one, exposing it as a parsed URL

Validation

  • cargo clippy --workspace --all-targets --all-features -- -D warnings clean
  • cargo hack --feature-powerset -p flowrs-airflow clippy --all-targets -- -D warnings clean across all 16 feature combinations (this touched a lot of cfg-gated code)
  • cargo test --workspace --lib --bins — 227 pass
  • cargo fmt --all --check clean

Follow-ups not in this PR

The individual managed-service helpers (get_conveyor_environment_servers, MwaaClient::*, …) are still pub and still return anyhow::Result. The app only uses expand_managed_services (now clean) and the Composer region helpers, so most of them could simply become pub(crate) — a smaller, separate change than converting them.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added standardized, actionable error reporting for invalid URLs, authentication failures, HTTP status errors (with response details), and response decoding issues.
    • Improved endpoint parsing/validation up front and standardized JSON response decoding across API operations.
  • Bug Fixes

    • API operation failures now reliably propagate instead of being treated as successful.
    • Task-instance pagination now advances using the number of records fetched, improving result accuracy.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a0026ca6-829d-4181-9fee-0fce8bd15597

📥 Commits

Reviewing files that changed from the base of the PR and between f4728a6 and 63f923c.

📒 Files selected for processing (2)
  • crates/flowrs-airflow/src/client/base.rs
  • crates/flowrs-airflow/src/error.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/flowrs-airflow/src/error.rs
  • crates/flowrs-airflow/src/client/base.rs

📝 Walkthrough

Walkthrough

The Airflow crate adds typed errors and shared request handling, migrates V1/V2 clients to centralized execution and JSON decoding, standardizes authentication and discovery failures, and propagates errors through higher-level operations.

Changes

Airflow error and shared transport

Layer / File(s) Summary
Error model and request pipeline
crates/flowrs-airflow/src/error.rs, crates/flowrs-airflow/src/client.rs, crates/flowrs-airflow/src/client/base.rs, crates/flowrs-airflow/src/lib.rs
Adds typed Airflow errors, endpoint validation, centralized HTTP execution, response-body snippets, and shared JSON decoding.
Authentication and managed-service errors
crates/flowrs-airflow/src/client/auth/*, crates/flowrs-airflow/src/managed_services/*
Authentication and managed-service paths now return crate-local authentication, discovery, and feature errors.
V1 client migration
crates/flowrs-airflow/src/client/v1/*
V1 requests use base_api, execute, and read_json instead of inline sending, status checks, and parsing.
V2 client migration
crates/flowrs-airflow/src/client/v2/*
V2 DAG, DAG-run, task, log, and task-instance requests use the shared execution and decoding path.
Operation propagation and URL handling
src/airflow/client/impls/*, src/airflow/client/open_url.rs
Higher-level operations propagate client failures, and open-URL builders accept parsed Url values.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operation
  participant V1V2Client
  participant BaseClient
  participant AirflowAPI
  Operation->>V1V2Client: invoke Airflow operation
  V1V2Client->>BaseClient: build and execute request
  BaseClient->>AirflowAPI: send HTTP request
  AirflowAPI-->>BaseClient: response status and body
  BaseClient-->>V1V2Client: Response or AirflowError
  V1V2Client-->>Operation: decoded value or propagated error
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.87% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: introducing a canonical Airflow error type for the library.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/airflow-error-type

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
crates/flowrs-airflow/src/error.rs (1)

84-106: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Status error drops query string, losing useful diagnostic context.

AirflowError::status stores only url.path(), discarding the query string. Several client calls attach pagination (offset/limit) or filter params via .query(...), so a failing request's error message won't show which page/params triggered the failure — undermining the stated goal of making failures diagnosable.

♻️ Proposed fix to include the query string
     pub(crate) fn status(
         method: &reqwest::Method,
         url: &url::Url,
         status: u16,
         body: &str,
     ) -> Self {
         Self::Status {
             method: method.to_string(),
-            path: url.path().to_string(),
+            path: match url.query() {
+                Some(q) => format!("{}?{q}", url.path()),
+                None => url.path().to_string(),
+            },
             status,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/flowrs-airflow/src/error.rs` around lines 84 - 106, Update
AirflowError::status to preserve the URL query string when populating the stored
path, using the URL’s path-and-query representation so pagination and filter
parameters remain visible in status errors while retaining the existing
body-detail behavior.
crates/flowrs-airflow/src/client/base.rs (1)

68-84: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Verify endpoint normalization: Url::join silently drops a base path segment without a trailing slash.

self.endpoint.join(&path) resolves path relative to self.endpoint. Per the url crate docs, a trailing slash is significant: without it, the last path component is treated as a "file" name and replaced rather than kept. So if a deployment's config.endpoint is something like https://host/airflow (no trailing slash), joining api/v1/dags yields https://host/api/v1/dags, silently dropping the /airflow prefix — a real risk for reverse-proxied deployments with a non-root path.

Since the endpoint is now parsed exactly once in new(), that's the natural place to guarantee a trailing slash so every subsequent join is safe.

♻️ Proposed normalization at construction
-        let endpoint = Url::parse(&config.endpoint)
+        let mut endpoint = Url::parse(&config.endpoint)
             .map_err(|e| AirflowError::invalid_url(config.endpoint.clone(), e))?;
+        if !endpoint.path().ends_with('/') {
+            endpoint.set_path(&format!("{}/", endpoint.path()));
+        }

Since my knowledge of this crate's exact intended endpoint-format contract is limited, please confirm whether configured endpoints are expected/documented to always include a trailing slash (or are always root-level) before applying this fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/flowrs-airflow/src/client/base.rs` around lines 68 - 84, Normalize the
parsed endpoint in the client constructor new() by ensuring its path ends with a
trailing slash before storing it, so base_api’s self.endpoint.join(&path)
preserves reverse-proxy prefixes such as /airflow. Confirm and follow the
existing configured-endpoint contract, and keep base_api unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/flowrs-airflow/src/client/base.rs`:
- Around line 68-84: Normalize the parsed endpoint in the client constructor
new() by ensuring its path ends with a trailing slash before storing it, so
base_api’s self.endpoint.join(&path) preserves reverse-proxy prefixes such as
/airflow. Confirm and follow the existing configured-endpoint contract, and keep
base_api unchanged.

In `@crates/flowrs-airflow/src/error.rs`:
- Around line 84-106: Update AirflowError::status to preserve the URL query
string when populating the stored path, using the URL’s path-and-query
representation so pagination and filter parameters remain visible in status
errors while retaining the existing body-detail behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 35d1c8ee-bcec-49f2-adbb-15d3d6c8e0c6

📥 Commits

Reviewing files that changed from the base of the PR and between c16ad73 and e5af34f.

⛔ Files ignored due to path filters (3)
  • Cargo.lock is excluded by !**/*.lock, !**/*.lock
  • Cargo.toml is excluded by !Cargo.toml, !**/Cargo.toml
  • crates/flowrs-airflow/Cargo.toml is excluded by !**/Cargo.toml
📒 Files selected for processing (31)
  • crates/flowrs-airflow/src/client.rs
  • crates/flowrs-airflow/src/client/auth/basic.rs
  • crates/flowrs-airflow/src/client/auth/command.rs
  • crates/flowrs-airflow/src/client/auth/mod.rs
  • crates/flowrs-airflow/src/client/auth/static_token.rs
  • crates/flowrs-airflow/src/client/base.rs
  • crates/flowrs-airflow/src/client/v1/dag.rs
  • crates/flowrs-airflow/src/client/v1/dagrun.rs
  • crates/flowrs-airflow/src/client/v1/dagstats.rs
  • crates/flowrs-airflow/src/client/v1/log.rs
  • crates/flowrs-airflow/src/client/v1/mod.rs
  • crates/flowrs-airflow/src/client/v1/task.rs
  • crates/flowrs-airflow/src/client/v1/taskinstance.rs
  • crates/flowrs-airflow/src/client/v2/dag.rs
  • crates/flowrs-airflow/src/client/v2/dagrun.rs
  • crates/flowrs-airflow/src/client/v2/dagstats.rs
  • crates/flowrs-airflow/src/client/v2/log.rs
  • crates/flowrs-airflow/src/client/v2/mod.rs
  • crates/flowrs-airflow/src/client/v2/task.rs
  • crates/flowrs-airflow/src/client/v2/taskinstance.rs
  • crates/flowrs-airflow/src/error.rs
  • crates/flowrs-airflow/src/lib.rs
  • crates/flowrs-airflow/src/managed_services/astronomer.rs
  • crates/flowrs-airflow/src/managed_services/composer/provider.rs
  • crates/flowrs-airflow/src/managed_services/conveyor.rs
  • crates/flowrs-airflow/src/managed_services/expand.rs
  • crates/flowrs-airflow/src/managed_services/mwaa.rs
  • src/airflow/client/impls/dag_ops.rs
  • src/airflow/client/impls/dagrun_ops.rs
  • src/airflow/client/impls/taskinstance_ops.rs
  • src/airflow/client/open_url.rs

@jvanbuel
jvanbuel force-pushed the refactor/airflow-error-type branch from e5af34f to f5974e0 Compare July 22, 2026 17:28
The library returned anyhow::Result from every public method, so callers could
not tell a connection failure from a 404 from a schema mismatch, and any
consumer was forced onto anyhow. Introduce AirflowError (thiserror) with the
categories a caller can act on, and a crate-level Result alias.

Three user-visible improvements fall out of this:

- Non-success responses now carry the response body. error_for_status() throws
  it away, which is exactly where Airflow puts its 'detail' explaining why a
  trigger/clear/mark was rejected; the TUI popup rendered only the status line.
- Every endpoint now reports parse failures with a body snippet. That handling
  existed in v1 only (parse_json_response); response.json() elsewhere gave a
  bare serde message with no context.
- The endpoint URL is parsed once when the client is built rather than on every
  request, so a bad endpoint is reported at connect time.

BaseClient::client/config and V1Client/V2Client::base become private, and
endpoint() returns the parsed &Url, which also removes the re-parse in the TUI's
open-url builders.

The managed-service integrations keep anyhow internally for their layered
.context() messages; the chain is flattened into the error message at the
public boundary so nothing is lost in the Display the TUI renders.
@jvanbuel

Copy link
Copy Markdown
Owner Author

Also removed AirflowApiClient / create_api_client: zero internal users (the TUI has its own FlowrsClient wrapper), an enum with no methods, untested. This PR already breaks the library's semver (error type, private fields, endpoint() signature), so the removal rides the same breaking release.

@jvanbuel
jvanbuel force-pushed the refactor/airflow-error-type branch from f5974e0 to f4728a6 Compare July 22, 2026 17:32
Normalize the parsed endpoint in BaseClient::new to end with a trailing
slash so base_api's relative join extends the endpoint instead of
replacing its final segment, keeping reverse-proxy prefixes like
/airflow. Also keep the URL query string when building Status errors so
pagination and filter parameters stay visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant