Skip to content

fix: return HTTP errors for failed skill downloads#9213

Merged
Soulter merged 3 commits into
AstrBotDevs:masterfrom
VectorPeak:fix/skill-archive-download-errors
Jul 12, 2026
Merged

fix: return HTTP errors for failed skill downloads#9213
Soulter merged 3 commits into
AstrBotDevs:masterfrom
VectorPeak:fix/skill-archive-download-errors

Conversation

@VectorPeak

@VectorPeak VectorPeak commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #9212

Skill archive download failures now return HTTP error statuses instead of HTTP 200 JSON bodies, so blob/ZIP callers do not save backend error envelopes as fake .zip files.

What Problem This Solves

The skill archive download endpoints succeed with FileResponse(..., media_type="application/zip"), but _download_skill() previously caught SkillsServiceError and returned error(str(exc)) directly.

Because error() is a normal JSON envelope, FastAPI serialized failed downloads as HTTP 200 JSON. The dashboard download flow calls skillApi.download() with responseType: 'blob', then wraps the response as application/zip before saving it as <skill>.zip. That means failed archive preparation could be persisted by the browser as a ZIP-looking file containing an error payload.

Modifications

  • Updated astrbot/dashboard/api/skills.py so failed skill archive downloads raise HTTPException instead of returning a 200 JSON envelope.

  • Added an optional SkillsServiceError.status_code attribute so the service layer can mark missing local skill archives as 404 without parsing exception text; other SkillsServiceError cases default to 400.

  • Preserved full unexpected-exception logging while returning a generic HTTP 500 message to clients for unexpected archive preparation failures.

  • Added focused FastAPI v1 regression coverage for 404, 400, and 500 archive failure paths; 404 is covered through both query-name and path-name archive endpoints.

  • Left successful ZIP downloads, skill upload/list/edit/delete behavior, frontend API code, OpenAPI generation, and normal JSON skill APIs unchanged.

  • This is NOT a breaking change. This is a focused bug fix.

Evidence

Before this change, a failed archive preparation path looked like this:

Dashboard Skills download button
  -> skillApi.download(skill.name)
  -> GET /api/v1/skills/archive?skill_name=...
  -> _download_skill()
  -> service.prepare_skill_archive(name)
  -> SkillsServiceError("Local skill not found")
  -> return error(str(exc))
  -> HTTP 200 JSON body
  -> frontend treats response as Blob/application/zip

After this change, the same failure path returns a non-2xx JSON error response, so blob/ZIP callers do not treat the failed download as a successful archive. The regression test also covers the 400 branch for default service errors and the 500 branch for unexpected exceptions, including the guarantee that the raw internal exception message is not returned to the client.

Possible call chain / impact

Dashboard skill archive download
  -> dashboard/src/api/v1.ts skillApi.download(..., responseType: 'blob')
  -> GET /api/v1/skills/archive or /api/v1/skills/{skill_name}/archive
  -> astrbot/dashboard/api/skills.py _download_skill()
  -> SkillsService.prepare_skill_archive(name)
  -> SkillsServiceError / unexpected exception
  -> HTTP 400/404/500 JSON error instead of HTTP 200 JSON error

This only changes failed skill archive download responses. Successful archive downloads still return application/zip, and ordinary skill JSON APIs keep the existing dashboard response envelope behavior.

Screenshots or Test Results

uv run pytest tests/test_fastapi_v1_dashboard.py -k "skill_archive_errors_return_http_status or safe_skill_routes_accept_slash_names" -q
..                                                                       [100%]
2 passed, 70 deselected, 1 warning in 3.83s
uv run pytest tests/test_fastapi_v1_dashboard.py -q
........................................................................ [100%]
72 passed, 1 warning in 19.54s
uv run ruff check astrbot/dashboard/api/skills.py astrbot/dashboard/services/skills_service.py tests/test_fastapi_v1_dashboard.py
All checks passed!
uv run ruff format astrbot/dashboard/api/skills.py astrbot/dashboard/services/skills_service.py tests/test_fastapi_v1_dashboard.py
2 files reformatted, 1 file left unchanged

Checklist

Summary by Sourcery

Return appropriate HTTP error statuses for failed skill archive downloads instead of successful ZIP responses.

Bug Fixes:

  • Ensure skill archive download failures return HTTP 404/400/500 JSON errors rather than HTTP 200 responses that appear as valid ZIP downloads.
  • Align skill archive error handling across both query-based and path-based endpoints to prevent saving backend error envelopes as ZIP files.

Tests:

  • Add regression test verifying skill archive endpoints return correct HTTP status codes and JSON error payloads when archive preparation fails.

Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
@dosubot dosubot Bot added size:XS This PR changes 0-9 lines, ignoring generated files. area:webui The bug / feature is about webui(dashboard) of astrbot. labels Jul 12, 2026

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request improves error handling in the skill download endpoint by raising specific HTTPExceptions (404, 400, or 500) instead of returning generic error responses, and adds a corresponding test case. The feedback suggests making the exception message matching more robust by avoiding exact string comparisons, and expanding the test suite to cover the newly introduced 400 and 500 error status codes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread astrbot/dashboard/api/skills.py Outdated
Comment on lines +97 to +98
message = str(exc)
status_code = 404 if message == "Local skill not found" else 400

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.

medium

Exact string matching on exception messages (message == "Local skill not found") is fragile. If the exception message in the service layer is ever modified (e.g., to include the skill name or for internationalization), this check will silently fail and fallback to a 400 status code instead of 404. Checking for the presence of "not found" in a case-insensitive manner is more robust.

Suggested change
message = str(exc)
status_code = 404 if message == "Local skill not found" else 400
message = str(exc)
status_code = 404 if "not found" in message.lower() else 400

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in b11eb0d. _download_skill() no longer checks the exact "Local skill not found" string; it now maps not-found service errors with case-insensitive "not found" matching so minor wording changes do not silently downgrade the response to 400.

Comment on lines +3230 to +3262
async def test_v1_skill_archive_errors_return_http_status(
asgi_app: FastAPI,
asgi_client: httpx.AsyncClient,
monkeypatch: pytest.MonkeyPatch,
):
skill_service = asgi_app.state.services.skills

def fake_prepare_skill_archive(_name: str):
raise SkillsServiceError("Local skill not found")

monkeypatch.setattr(
skill_service,
"prepare_skill_archive",
fake_prepare_skill_archive,
)

by_name_response = await asgi_client.get(
"/api/v1/skills/archive",
params={"skill_name": "missing_skill"},
headers=_jwt_headers(),
)
path_response = await asgi_client.get(
"/api/v1/skills/missing_skill/archive",
headers=_jwt_headers(),
)

assert by_name_response.status_code == 404
assert by_name_response.headers["content-type"].startswith("application/json")
assert by_name_response.json()["status"] == "error"
assert by_name_response.json()["message"] == "Local skill not found"
assert path_response.status_code == 404
assert path_response.headers["content-type"].startswith("application/json")
assert path_response.json()["message"] == "Local skill not found"

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.

medium

The test currently only covers the 404 error path. Since this PR introduces new error handling that maps different exceptions to 400 and 500 status codes, it is highly recommended to expand the test suite to cover these cases as well to ensure full coverage and prevent future regressions.

async def test_v1_skill_archive_errors_return_http_status(
    asgi_app: FastAPI,
    asgi_client: httpx.AsyncClient,
    monkeypatch: pytest.MonkeyPatch,
):
    skill_service = asgi_app.state.services.skills

    # Test 404 Not Found
    def fake_prepare_skill_archive_404(_name: str):
        raise SkillsServiceError("Local skill not found")

    monkeypatch.setattr(
        skill_service,
        "prepare_skill_archive",
        fake_prepare_skill_archive_404,
    )

    by_name_response = await asgi_client.get(
        "/api/v1/skills/archive",
        params={"skill_name": "missing_skill"},
        headers=_jwt_headers(),
    )
    path_response = await asgi_client.get(
        "/api/v1/skills/missing_skill/archive",
        headers=_jwt_headers(),
    )

    assert by_name_response.status_code == 404
    assert by_name_response.headers["content-type"].startswith("application/json")
    assert by_name_response.json()["status"] == "error"
    assert by_name_response.json()["message"] == "Local skill not found"
    assert path_response.status_code == 404
    assert path_response.headers["content-type"].startswith("application/json")
    assert path_response.json()["message"] == "Local skill not found"

    # Test 400 Bad Request
    def fake_prepare_skill_archive_400(_name: str):
        raise SkillsServiceError("Invalid skill name")

    monkeypatch.setattr(
        skill_service,
        "prepare_skill_archive",
        fake_prepare_skill_archive_400,
    )

    bad_response = await asgi_client.get(
        "/api/v1/skills/archive",
        params={"skill_name": "invalid_skill"},
        headers=_jwt_headers(),
    )
    assert bad_response.status_code == 400
    assert bad_response.json()["status"] == "error"
    assert bad_response.json()["message"] == "Invalid skill name"

    # Test 500 Internal Server Error
    def fake_prepare_skill_archive_500(_name: str):
        raise RuntimeError("Unexpected database error")

    monkeypatch.setattr(
        skill_service,
        "prepare_skill_archive",
        fake_prepare_skill_archive_500,
    )

    server_error_response = await asgi_client.get(
        "/api/v1/skills/archive",
        params={"skill_name": "error_skill"},
        headers=_jwt_headers(),
    )
    assert server_error_response.status_code == 500
    assert server_error_response.json()["status"] == "error"
    assert "Unexpected database error" in server_error_response.json()["message"]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in b11eb0d. The regression test now covers the 404, 400, and 500 archive failure paths. The 500 case also asserts that the raw internal exception text is not returned to the client; clients receive the generic Failed to prepare skill archive message while the full exception remains logged server-side.

@sourcery-ai sourcery-ai 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.

Hey - I've left some high level feedback:

  • Mapping SkillsServiceError to HTTP 404 by comparing the exception message string ("Local skill not found") is brittle and will break if messages are localized or changed; consider adding a specific error type/attribute or error code on SkillsServiceError and branching on that instead.
  • The generic except Exception path in _download_skill raises a 500 with the raw exception message as the response detail, which may leak internal information; consider returning a generic message to clients and logging the full exception separately.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Mapping `SkillsServiceError` to HTTP 404 by comparing the exception message string (`"Local skill not found"`) is brittle and will break if messages are localized or changed; consider adding a specific error type/attribute or error code on `SkillsServiceError` and branching on that instead.
- The generic `except Exception` path in `_download_skill` raises a 500 with the raw exception message as the response detail, which may leak internal information; consider returning a generic message to clients and logging the full exception separately.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
@dosubot dosubot Bot added size:S This PR changes 10-29 lines, ignoring generated files. and removed size:XS This PR changes 0-9 lines, ignoring generated files. labels Jul 12, 2026
@VectorPeak

Copy link
Copy Markdown
Contributor Author

Reviewer feedback has been addressed in b11eb0d:

  • Replaced the exact "Local skill not found" comparison with case-insensitive not-found classification for archive service errors.
  • Kept full server-side exception logging, but changed unexpected archive failures to return a generic client-facing 500 message.
  • Expanded the FastAPI v1 regression test to cover 404, 400, and 500 archive failure responses, including the no-raw-exception-leak guarantee for 500s.
  • Updated the PR body validation output to reflect the latest local run.

Local validation after the reviewer changes:

uv run ruff format astrbot/dashboard/api/skills.py tests/test_fastapi_v1_dashboard.py
2 files reformatted

uv run ruff check astrbot/dashboard/api/skills.py tests/test_fastapi_v1_dashboard.py
All checks passed!

uv run pytest tests/test_fastapi_v1_dashboard.py -k "skill_archive_errors_return_http_status or safe_skill_routes_accept_slash_names" -q
2 passed, 70 deselected, 1 warning in 4.02s

uv run pytest tests/test_fastapi_v1_dashboard.py -q
72 passed, 1 warning in 19.94s

git diff --check
passed

Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
@VectorPeak

Copy link
Copy Markdown
Contributor Author

Follow-up reviewer change pushed in 3314d1e:

  • Replaced the remaining message-text based 404 mapping with a structured SkillsServiceError.status_code attribute.
  • prepare_skill_archive() now raises SkillsServiceError("Local skill not found", status_code=404) for the missing-local-archive branch.
  • _download_skill() now trusts the service-layer exc.status_code instead of parsing str(exc).
  • Existing SkillsServiceError(...) call sites keep default 400 behavior, so non-archive skill APIs remain unchanged.

Latest local validation:

uv run ruff format astrbot/dashboard/api/skills.py astrbot/dashboard/services/skills_service.py tests/test_fastapi_v1_dashboard.py
2 files reformatted, 1 file left unchanged

uv run ruff check astrbot/dashboard/api/skills.py astrbot/dashboard/services/skills_service.py tests/test_fastapi_v1_dashboard.py
All checks passed!

uv run pytest tests/test_fastapi_v1_dashboard.py -k "skill_archive_errors_return_http_status or safe_skill_routes_accept_slash_names" -q
2 passed, 70 deselected, 1 warning in 3.83s

uv run pytest tests/test_fastapi_v1_dashboard.py -q
72 passed, 1 warning in 19.54s

git diff --check
passed

@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Jul 12, 2026
@Soulter Soulter merged commit a957b2e into AstrBotDevs:master Jul 12, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. lgtm This PR has been approved by a maintainer size:S This PR changes 10-29 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Skill archive download failures return 200 JSON instead of an HTTP error

2 participants