Skip to content

Commit 6dc75ee

Browse files
Merge pull request #50 from Botts-Innovative-Research/fix/insert-self-silent-failure
Raise on failed system POST instead of swallowing it
2 parents 32c13f5 + 6202feb commit 6dc75ee

7 files changed

Lines changed: 85 additions & 3 deletions

File tree

docs/source/architecture/insertion.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ The same pattern applies if you skip the `OSHConnect` convenience and
4444
build a `System` directly: just call `system.insert_self()` and the wrapper
4545
handles dump → POST → ID-capture itself.
4646

47+
If the server rejects the POST, `insert_self()` raises with the status
48+
code and response body rather than returning quietly — otherwise the
49+
system's `_resource_id` would stay `None` and the failure would only
50+
resurface much later, from the first child-resource call that needs it.
51+
4752
## Inserting a Datastream
4853

4954
Similar shape, but the body is wrapped inside a

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "oshconnect"
3-
version = "0.5.2a1"
3+
version = "0.5.3a1"
44
description = "Library for interfacing with OSH, helping guide visualization efforts, and providing a place to store configurations. Implements OGC CS API Part 3 (Pub/Sub) MQTT topic conventions including :data topics and resource event topics."
55
readme = "README.md"
66
authors = [

src/oshconnect/node.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,11 +354,19 @@ def add_system(self, system: System, insert_resource: bool = False) -> System:
354354
in-memory only; useful when reconstructing state from a
355355
datastore or staging a system before a deferred POST.
356356
357+
A failed POST propagates out of ``insert_self()`` and the system
358+
is *not* attached — better a loud failure here than a system that
359+
looks attached but has no server-side id, which previously only
360+
surfaced later as an ``AttributeError`` from the first child
361+
resource call.
362+
357363
:param system: ``System`` object to attach.
358364
:param insert_resource: Whether to POST the system to the
359365
server before attaching it locally.
360366
:return: The same ``System`` (now parented to this node and
361367
tracked in ``self.systems()``).
368+
:raises Exception: if ``insert_resource=True`` and the server
369+
rejects the POST.
362370
"""
363371
if insert_resource:
364372
system.insert_self()

src/oshconnect/resources/base.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ class StreamableResource(Generic[T], ABC):
107107
:param connection_mode: One of `StreamableModes`. Default ``PUSH``.
108108
"""
109109
_id: UUID
110-
_resource_id: str
110+
_resource_id: str | None
111111
# _canonical_link: str
112112
_topic: str
113113
_status: str = Status.STOPPED.value
@@ -135,6 +135,11 @@ def __init__(self, node: Node, connection_mode: StreamableModes = StreamableMode
135135
self._outbound_deque = deque()
136136
self._subscribe_topic = None
137137
self._parent_resource_id = None
138+
# Always present, even before the resource exists server-side, so
139+
# pre-insert access is a clean `is None` check rather than an
140+
# AttributeError far from the failed POST that caused it.
141+
# Subclasses overwrite this when they know the server-assigned id.
142+
self._resource_id = None
138143

139144
def get_streamable_id(self) -> UUID:
140145
"""Return the local UUID assigned at construction (not the server-side ID)."""

src/oshconnect/resources/system.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,12 @@ def insert_self(self):
549549
the body before POST so a re-POSTed (e.g. cross-node-synced)
550550
system doesn't leak the source server's identifier or links to
551551
the destination — the destination assigns its own.
552+
553+
:raises Exception: if the server returns a non-OK response. The
554+
failure is raised here rather than swallowed — otherwise
555+
``_resource_id`` stays ``None`` and the error resurfaces much
556+
later (and much less legibly) from the first child-resource
557+
call that needs the system's id.
552558
"""
553559
body_resource = self.to_system_resource().model_copy(deep=True)
554560
body_resource.system_id = None
@@ -564,6 +570,11 @@ def insert_self(self):
564570
self._resource_id = sys_id
565571
if self._underlying_resource is not None:
566572
self._underlying_resource.system_id = sys_id
573+
else:
574+
raise Exception(
575+
f'Failed to insert system {self.label!r} ({self.urn!r}): '
576+
f'HTTP {res.status_code}{res.text}'
577+
)
567578

568579
def retrieve_resource(self):
569580
"""GET ``/systems/{id}`` and refresh the underlying `SystemResource`.

tests/test_csapi_serialization.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,59 @@ def test_insert_self_strips_id_and_links_from_body(node, monkeypatch):
311311
assert sys._resource_id == "dest-id-xyz"
312312

313313

314+
def test_insert_self_raises_on_failed_post(node, monkeypatch):
315+
"""A rejected POST must raise with the status code and body, not be
316+
swallowed. Previously `insert_self()` returned normally on a non-ok
317+
response, leaving `_resource_id` unset — the failure only surfaced
318+
later as an AttributeError from `add_insert_datastream()`. See
319+
GitHub issue #42."""
320+
sys = System(label="Doomed", urn="urn:test:fail:1", parent_node=node)
321+
322+
capture_request(monkeypatch, "post", response=MockResponse(
323+
payload={"error": "disk full"}, status=500))
324+
325+
# Status code and response body both belong in the message — they are
326+
# the only diagnostic the caller gets.
327+
with pytest.raises(Exception, match=r"HTTP 500"):
328+
sys.insert_self()
329+
with pytest.raises(Exception, match=r"disk full"):
330+
sys.insert_self()
331+
332+
333+
def test_add_system_does_not_attach_on_failed_insert(node, monkeypatch):
334+
"""The issue's actual repro: `add_system(insert_resource=True)` against
335+
a node that rejects the POST must raise, and must not leave a system
336+
with no server-side id sitting in the node's collection. See GitHub
337+
issue #42."""
338+
sys = System(label="Doomed", urn="urn:test:fail:2", parent_node=node)
339+
340+
capture_request(monkeypatch, "post", response=MockResponse(status=500))
341+
342+
with pytest.raises(Exception, match=r"Failed to insert system"):
343+
node.add_system(sys, insert_resource=True)
344+
assert sys not in node.systems()
345+
346+
347+
def test_resource_id_is_none_before_insert(node):
348+
"""`_resource_id` exists (as None) on every wrapper from construction,
349+
so pre-insert access is a clean None check rather than an
350+
AttributeError. Guards the `from_resource`-without-id path too, which
351+
never passed a `resource_id` kwarg. See GitHub issue #42."""
352+
sys = System(label="Uninserted", urn="urn:test:noid:1", parent_node=node)
353+
assert sys._resource_id is None
354+
355+
res = SystemResource.from_smljson_dict({
356+
"type": "PhysicalSystem",
357+
"uniqueId": "urn:test:noid:2",
358+
"label": "No Server Id",
359+
})
360+
from_res = System.from_resource(res, node)
361+
assert from_res._resource_id is None
362+
# retrieve_resource() already guards on `is None`; without the base
363+
# init it would AttributeError before reaching that check.
364+
assert from_res.retrieve_resource() is None
365+
366+
314367
# ===========================================================================
315368
# Datastream: resource representation, schema document, observations
316369
# ===========================================================================

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)