Coverage for server / utilities / assignment_dedupe.py: 97%

35 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-10-04 09:33 +0000

1"""One guard that stops a retried or double-clicked create from duplicating an assignment. 

2 

3THE PROBLEM 

4----------- 

5`POST /v1/assignments/create` (and the teacher-scoped `POST /v1/teacher/assignment/create`) 

6insert unconditionally. Two identical requests produce two assignments: 

7 

8* **Sequential retry** (EI-3450 / Zephyr EI-T568) — a network retry, or a double-click 

9 before the button disables. The first write already succeeded; the client, having seen 

10 no response or an error, sends it again. 

11* **Concurrent race** (EI-3451 / Zephyr EI-T574) — two identical requests in flight at 

12 once, so a read-then-write check would pass in both before either insert lands. 

13 

14This has bitten before for a reason worth recording: `routes/common/assignments.py` 

15carried `response_model=Assignment` over a route that returns an *envelope*, so FastAPI 

16raised `ResponseValidationError` **after** the insert had committed. Every call created an 

17assignment and reported failure, and a retrying client duplicated it (EI-T113, fixed). 

18The response bug is gone; the duplicate-on-retry gap it exposed is what this module closes. 

19 

20WHY A UNIQUE INDEX AND NOT A PRE-INSERT LOOKUP 

21---------------------------------------------- 

22A read-then-write check closes the sequential case and not the concurrent one — both 

23readers see nothing, both insert. Only the database can arbitrate a race. EI-3451 asks for 

24"the same guard, not two separate mechanisms", and a unique index is literally that: one 

25constraint underneath all three create paths, so it cannot be bypassed by whichever 

26service happens to do the insert. 

27 

28WHY NOT AN `Idempotency-Key` HEADER 

29----------------------------------- 

30The IETF draft (`draft-ietf-httpapi-idempotency-key-header`, Standards Track) expects a 

31*client* that generates a key and resends it on retry. The clients that cause this bug are 

32a double-clicked button and an automatic network retry — neither sends such a header, so a 

33header-based guard would sit unreachable from the very traffic it is meant to catch. The 

34header earns its keep when the payload is genuinely arbitrary (payments). Here the request 

35carries a natural identity, so we use it. 

36 

37WHAT COUNTS AS "THE SAME ASSIGNMENT" 

38------------------------------------ 

39`(created_by, assigned_class, title, date_open, date_close)`, hashed. Deliberately narrow: 

40two assignments differing in any of those are different assignments. `assigned_class` is 

41sorted before hashing so class order in the payload cannot produce two different keys for 

42the same request. 

43 

44THE TIME WINDOW, AND WHY IT IS NOT OPTIONAL 

45------------------------------------------- 

46A *permanent* uniqueness constraint on that key would over-deliver. It would forbid a 

47teacher from ever re-creating the same-titled assignment for the same class on the same 

48dates — a business rule nobody asked for, and not what EI-3450 describes. Worse, replaying 

49the original assignment for a genuine months-later duplicate would hand the caller a 

50*different* assignment than the one they asked to create: a silent wrong answer, which is 

51worse than an error. 

52 

53So the key is permanent in the index but the *interpretation* is time-boxed, which mirrors 

54how Stripe scopes idempotency keys with an expiry: 

55 

56* collision within :data:`DEDUPE_WINDOW_SECONDS` — a retry or a race. Return the assignment 

57 that already exists, 200, same envelope. Idempotent, and EI-3450's "returns the original". 

58* collision after the window — not a retry. **409** with a message that says what happened, 

59 so the caller can rename or change the dates. EI-3450 allows this outcome explicitly. 

60 

61Developer: Allan Ninal 

62""" 

63 

64from __future__ import annotations 

65 

66import hashlib 

67from datetime import datetime, timedelta, timezone 

68from typing import Any, Iterable, Optional 

69 

70# A retry or a double-click lands within seconds. 60s is wide enough to cover a slow 

71# connection's automatic retry and narrow enough that a deliberate re-creation minutes 

72# later is treated as what it is: a new assignment, not a replay. 

73DEDUPE_WINDOW_SECONDS = 60 

74 

75# Name kept in one place so the index and any future migration cannot drift apart. 

76DEDUPE_INDEX_NAME = "assignment_dedupe_key_unique_sparse" 

77 

78 

79def _normalise_date(value: Optional[datetime]) -> str: 

80 """Render a date as a stable string. 

81 

82 Naive datetimes are treated as UTC. Without this, the same instant submitted once with 

83 a timezone and once without would hash differently and the retry would slip through. 

84 """ 

85 if value is None: 

86 return "" 

87 if value.tzinfo is None: 

88 value = value.replace(tzinfo=timezone.utc) 

89 return value.astimezone(timezone.utc).isoformat() 

90 

91 

92def compute_dedupe_key( 

93 created_by: Any, 

94 assigned_class: Optional[Iterable[Any]], 

95 title: Optional[str], 

96 date_open: Optional[datetime], 

97 date_close: Optional[datetime], 

98) -> str: 

99 """Hash the fields that make two create requests the same request. 

100 

101 Returns a hex digest. `assigned_class` is sorted by string form so the payload's class 

102 ordering cannot change the key, and `title` is stripped so a stray trailing space does 

103 not read as a different assignment. 

104 """ 

105 classes = sorted(str(item) for item in (assigned_class or [])) 

106 parts = [ 

107 str(created_by or ""), 

108 ",".join(classes), 

109 (title or "").strip(), 

110 _normalise_date(date_open), 

111 _normalise_date(date_close), 

112 ] 

113 # Each field is LENGTH-PREFIXED rather than separator-joined, so no field's content can 

114 # impersonate a field boundary. 

115 # 

116 # A plain join is ambiguous even with an exotic separator, which mutation testing on 

117 # this function is what surfaced: with any separator S and a fixed field count, 

118 # ("a"+S+"b", ["c"]) and ("a", ["b"+S+"c"]) render the SAME string, because moving S 

119 # across the boundary leaves the total unchanged. Neither input is reachable today — 

120 # `created_by` is a server-set auth id and class ids are ObjectIds — but the encoding 

121 # costs nothing to get right, and "the inputs happen to be safe" is a property of 

122 # today's callers, not of this function. 

123 canonical = "".join(f"{len(part)}:{part}" for part in parts) 

124 return hashlib.sha256(canonical.encode("utf-8")).hexdigest() 

125 

126 

127def is_within_dedupe_window( 

128 created_at: Optional[datetime], now: Optional[datetime] = None 

129) -> bool: 

130 """True when `created_at` is recent enough for a collision to be a retry. 

131 

132 A missing `created_at` returns False: with nothing to date the existing assignment by, 

133 the safe reading is "not a retry", which surfaces a 409 rather than silently handing 

134 back some older assignment. 

135 """ 

136 if created_at is None: 

137 return False 

138 if created_at.tzinfo is None: 

139 created_at = created_at.replace(tzinfo=timezone.utc) 

140 now = now or datetime.now(timezone.utc) 

141 if now.tzinfo is None: 

142 now = now.replace(tzinfo=timezone.utc) 

143 # A clock skew that puts created_at slightly in the future is still a retry. 

144 return abs(now - created_at) <= timedelta(seconds=DEDUPE_WINDOW_SECONDS) 

145 

146 

147async def resolve_duplicate_create(dedupe_key: str): 

148 """Decide what a rejected duplicate insert should return. 

149 

150 Called only after the unique index has refused an insert, so a matching assignment 

151 exists by construction. Returns the existing :class:`Assignment` when the collision 

152 looks like a retry or a race; raises 409 when it does not. 

153 

154 Every create path funnels through here, so the retry-vs-conflict decision is made in 

155 exactly one place (EI-3451). 

156 """ 

157 # Imported here rather than at module scope: this module is imported by 

158 # server/connection/database.py, which also imports the models, and a top-level import 

159 # would tie the two together for no benefit. 

160 from fastapi import HTTPException, status 

161 

162 from server.models.assignment import Assignment 

163 

164 existing = await Assignment.find_one({"dedupe_key": dedupe_key}) 

165 

166 if existing is None: 

167 # The index said this key is taken but the row is not readable — a hard delete 

168 # landing between the failed insert and this read is the realistic cause. Nothing 

169 # to replay, and claiming success would be a lie, so say what happened. 

170 raise HTTPException( 

171 status_code=status.HTTP_409_CONFLICT, 

172 detail="An identical assignment was created concurrently. Please try again.", 

173 ) 

174 

175 if is_within_dedupe_window(existing.created_at): 

176 return existing 

177 

178 raise HTTPException( 

179 status_code=status.HTTP_409_CONFLICT, 

180 detail=( 

181 "An assignment with the same title, class and dates already exists. " 

182 "Change the title or the dates to create another one." 

183 ), 

184 )