Skip to content

Commit b0e11b2

Browse files
tonylampadaclaude
andcommitted
Raise ProjectNotFoundError when a project lookup 404s (#302)
workspace.project("typo") raised a RoboflowError carrying the raw API JSON, which names neither the workspace nor the project and doesn't say that a trashed project answers the same 404 as a missing one. Map 404 from GET /:workspace/:project to ProjectNotFoundError, which names both and lists the three causes. Roboflow.project() now goes through the same adapter; the new error also subclasses RuntimeError so existing except clauses still hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b9ec136 commit b0e11b2

3 files changed

Lines changed: 107 additions & 8 deletions

File tree

‎roboflow/__init__.py‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -339,14 +339,13 @@ def project(self, project_name, the_workspace=None):
339339
else:
340340
the_workspace = self.current_workspace
341341

342-
dataset_info = requests.get(API_URL + "/" + the_workspace + "/" + project_name + "?api_key=" + self.api_key)
343-
344-
# Throw error if dataset isn't valid/user doesn't have
345-
# permissions to access the dataset
346-
if dataset_info.status_code != 200:
347-
raise RuntimeError(dataset_info.text)
348-
349-
dataset_info = dataset_info.json()["project"]
342+
try:
343+
dataset_info = rfapi.get_project(self.api_key, the_workspace, project_name)["project"]
344+
except rfapi.ProjectNotFoundError:
345+
raise
346+
except rfapi.RoboflowError as e:
347+
# keep the RuntimeError callers have always caught
348+
raise RuntimeError(str(e)) from e
350349

351350
return Project(self.api_key, dataset_info)
352351

‎roboflow/adapters/rfapi.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,26 @@ def __init__(self, message, status_code=None):
2828
self.status_code = status_code
2929

3030

31+
class ProjectNotFoundError(RoboflowError, RuntimeError):
32+
"""The API answered 404 for a project lookup.
33+
34+
Roboflow answers 404 alike for a project that never existed, one that
35+
was deleted (in Trash) and one the API key cannot see. Subclasses
36+
`RuntimeError` because `Roboflow.project()` raised that before.
37+
"""
38+
39+
def __init__(self, workspace_url, project_url):
40+
super().__init__(
41+
f"Project '{project_url}' not found in workspace '{workspace_url}'. "
42+
"It may not exist, may have been deleted (check the workspace Trash), "
43+
"or your API key may not have access to it. "
44+
"Check the project id: it is the URL slug, e.g. 'my-project', not the display name.",
45+
status_code=404,
46+
)
47+
self.workspace_url = workspace_url
48+
self.project_url = project_url
49+
50+
3151
class ImageUploadError(RoboflowError):
3252
def __init__(self, message, status_code=None):
3353
self.message = message
@@ -54,6 +74,8 @@ def get_workspace(api_key, workspace_url):
5474
def get_project(api_key, workspace_url, project_url):
5575
url = f"{API_URL}/{workspace_url}/{project_url}?api_key={api_key}"
5676
response = requests.get(url)
77+
if response.status_code == 404:
78+
raise ProjectNotFoundError(workspace_url, project_url)
5779
if response.status_code != 200:
5880
raise RoboflowError(response.text, status_code=response.status_code)
5981
result = response.json()

‎tests/test_project_not_found.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""A project id that resolves to nothing must raise, not return a Project (#302)."""
2+
3+
import responses
4+
5+
from roboflow.adapters.rfapi import ProjectNotFoundError, RoboflowError
6+
from roboflow.config import API_URL
7+
from tests import ROBOFLOW_API_KEY, WORKSPACE_NAME, RoboflowTest
8+
9+
MISSING = "my_project"
10+
11+
# What the API answers for a missing, trashed or inaccessible project.
12+
NOT_FOUND_BODY = {
13+
"error": {
14+
"message": f"Unsupported request. `GET /{WORKSPACE_NAME}/{MISSING}` does not exist "
15+
"or cannot be loaded due to missing permissions.",
16+
"type": "GraphMethodException",
17+
}
18+
}
19+
20+
21+
class TestProjectNotFound(RoboflowTest):
22+
def _answer(self, status, body):
23+
responses.add(
24+
responses.GET,
25+
f"{API_URL}/{WORKSPACE_NAME}/{MISSING}?api_key={ROBOFLOW_API_KEY}",
26+
json=body,
27+
status=status,
28+
)
29+
30+
def test_workspace_project_raises_naming_workspace_and_project(self):
31+
self._answer(404, NOT_FOUND_BODY)
32+
33+
with self.assertRaises(ProjectNotFoundError) as ctx:
34+
self.workspace.project(MISSING)
35+
36+
message = str(ctx.exception)
37+
self.assertIn(f"'{MISSING}'", message)
38+
self.assertIn(f"'{WORKSPACE_NAME}'", message)
39+
self.assertIn("Trash", message)
40+
self.assertEqual(ctx.exception.status_code, 404)
41+
self.assertEqual(ctx.exception.workspace_url, WORKSPACE_NAME)
42+
self.assertEqual(ctx.exception.project_url, MISSING)
43+
44+
def test_missing_project_never_reaches_upload(self):
45+
self._answer(404, NOT_FOUND_BODY)
46+
47+
with self.assertRaises(ProjectNotFoundError):
48+
self.workspace.project(MISSING).upload(image_path="tests/images/hand-rabbit.PNG")
49+
50+
upload_calls = [c for c in responses.calls if "/upload" in c.request.url]
51+
self.assertEqual(upload_calls, [])
52+
53+
def test_roboflow_project_raises_and_stays_a_runtime_error(self):
54+
self._answer(404, NOT_FOUND_BODY)
55+
56+
with self.assertRaises(ProjectNotFoundError) as ctx:
57+
self.rf.project(f"{WORKSPACE_NAME}/{MISSING}")
58+
59+
self.assertIsInstance(ctx.exception, RuntimeError)
60+
self.assertIn(f"'{WORKSPACE_NAME}'", str(ctx.exception))
61+
62+
def test_other_errors_keep_the_api_body(self):
63+
self._answer(500, {"error": "boom"})
64+
65+
with self.assertRaises(RoboflowError) as ctx:
66+
self.workspace.project(MISSING)
67+
68+
self.assertNotIsInstance(ctx.exception, ProjectNotFoundError)
69+
self.assertIn("boom", str(ctx.exception))
70+
self.assertEqual(ctx.exception.status_code, 500)
71+
72+
def test_roboflow_project_other_errors_stay_runtime_error(self):
73+
self._answer(403, {"error": "forbidden"})
74+
75+
with self.assertRaises(RuntimeError) as ctx:
76+
self.rf.project(f"{WORKSPACE_NAME}/{MISSING}")
77+
78+
self.assertIn("forbidden", str(ctx.exception))

0 commit comments

Comments
 (0)